Oasis 0.8.7 mobile (nodejs-project baseline)

This commit is contained in:
s1to 2026-07-09 09:56:30 +02:00
commit e563b86ede
226 changed files with 141968 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

328
README.md Normal file
View file

@ -0,0 +1,328 @@
# Oasis
Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application
that helps you follow interesting content and discover new ones.
![SNH](https://solarnethub.com/git/snh-oasis-logo3.jpg "SolarNET.HuB")
Oasis redefines what it means to be connected in the modern world, giving people
the ability to control their online presence and interactions without the need for centralized institutions.
----------
## Frontend:
Main features of the Oasis interface are:
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
+ No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
+ Use your favorite web browser to read and write messages to the people you care about.
+ Strong cryptography in every single point of the network.
+ You are the center of your own distributed network. Online or offline, it works anywhere that you are.
+ Initial identities are randomnly generated (no username or password required).
+ No personal profile generated (no questions about gender, age, location, etc …).
+ Automatic exif stripping (such as GPS coordinates) on images for better privacy.
+ No email or associated mobile phone required.
+ Automatic updates with new functionalities.
![SNH](https://solarnethub.com/git/snh-oasis-settings.png "SolarNET.HuB")
But it has others features that are also really interesting, for example:
+ Support for multiple languages.
![SNH](https://solarnethub.com/git/snh-oasis-languages.png "SolarNET.HuB")
+ Modularity to set your own environment.
![SNH](https://solarnethub.com/git/snh-oasis-modules.png "SolarNET.HuB")
+ Support for multiple themes (including Mobile Theme).
![SNH](https://solarnethub.com/git/snh-clear-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-purple-theme.png "SolarNET.HuB")
![SNH](https://solarnethub.com/git/snh-matrix-theme.png "SolarNET.HuB")
And some other nice modules, like for example:
+ A complex Reddit-styled forum system.
![SNH](https://solarnethub.com/git/snh-forum.png "SolarNET.HuB")
+ Or a collaborative -offline- maps system.
![SNH](https://solarnethub.com/git/snh-maps.jpeg "SolarNET.HuB")
And much more, that we invite you to discover by yourself ;-)
![SNH](https://solarnethub.com/git/snh-games.jpeg "SolarNET.HuB")
----------
## Modules:
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
+ Agenda: Module to manage all your assigned items.
+ AI: Module to talk with a LLM called '42'.
+ AINav: Module for natural-language queries about the network's content.
+ Audios: Module to discover and manage audios.
+ Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
+ BlockExplorer: Module to navigate the blockchain.
+ Bookmarks: Module to discover and manage bookmarks.
+ Calendars: Module to discover and manage calendars.
+ Chats: Module to discover and manage encrypted chats.
+ Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
+ Courts: Module to resolve conflicts and emit veredicts.
+ Documents: Module to discover and manage documents.
+ Events: Module to discover and manage events.
+ Favorites: Module to manage your favorite content.
+ Fediverse: Manage your other fediverse accounts, including sending and receiving content.
+ Feed: Module to discover and share short-texts (feeds).
+ Forums: Module to discover and manage forums.
+ Games: Module to play and share your scores in various mini-games.
+ Governance: Module to discover and manage votes.
+ Graphos: Module to explore the network as an interactive map of peers.
+ Images: Module to discover and manage images.
+ Invites: Module to manage and apply invite codes.
+ Jobs: Module to discover and manage jobs.
+ Legacy: Module to manage your secret (private key) quickly and securely.
+ Latest: Module to receive the most recent posts and discussions.
+ L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
+ Logs: Module to record (via AI assistant) your experiences.
+ Maps: Module to manage and share offline maps.
+ Market: Module to exchange goods or services.
+ Melody: Module to generate and share the "sound" of your blockchain.
+ Multiverse: Module to receive content from other federated peers.
+ Opinions: Module to discover and vote on opinions.
+ Pads: Module to manage collaborative encrypted text editors.
+ Parliament: Module to elect governments and vote on laws.
+ Pixelia: Module to draw on a collaborative grid.
+ Projects: Module to explore, crowd-funding and manage projects.
+ Popular: Module to receive posts that are trending, most viewed, or most commented on.
+ Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
+ Shops: Module to manage and discover shops.
+ Summaries: Module to receive summaries of long discussions or posts.
+ Tags: Module to discover and explore taxonomy patterns (tags).
+ Tasks: Module to discover and manage tasks.
+ Threads: Module to receive conversations grouped by topic or question.
+ Topics: Module to receive discussion categories based on shared interests.
+ Torrents: Module to explore and manage torrents.
+ Transfers: Module to discover and manage smart-contracts (transfers).
+ Trending: Module to explore the most popular content.
+ Tribes: Module to explore or create tribes (groups).
+ Videos: Module to discover and manage videos.
+ Wallet: Module to manage your digital assets (ECOin).
Both the codebase and the inhabitants can generate new modules.
----------
## C-AI (collective artificial intelligence)
Oasis contains its own AI model called "42".
The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias.
![SNH](https://solarnethub.com/git/oasis-ai-example2.png "SolarNET.HuB")
Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems.
+ https://wiki.solarnethub.com/socialnet/ai
----------
## Parliament
Oasis contains its own Parliament (Government system).
![SNH](https://solarnethub.com/git/oasis-parliament.png "SolarNET.HuB")
## Courts
Oasis contains its own Courts (Justice system).
![SNH](https://solarnethub.com/git/oasis-courts.png "SolarNET.HuB")
## ECOin
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
![SNH](https://solarnethub.com/git/oasis-tomatoes-example.png "SolarNET.HuB")
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
+ https://ecoin.03c8.net
## Banking
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB")
----------
## Carbon Footprinting
Oasis contains its own carbon footprint meter.
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting.png "SolarNET.HuB")
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting2.png "SolarNET.HuB")
And also of each inhabitant.
![SNH](https://solarnethub.com/git/snh-oasis-carbon-foorprinting3.png "SolarNET.HuB")
----------
## L.A.R.P.
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses.
![SNH](https://solarnethub.com/git/oasis-larp-schema.jpg "SolarNET.HuB")
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
Check "The Houses" to review which one fit better with your ambitions:
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
----------
## Fediverse
Oasis bridges to the **Fediverse**. You can connect your fediverse accounts (ex: **Mastodon**) and use them for content from inside Oasis: read your home timeline, publish (text, images and video), reply, boost and favourite — without storing any third-party content in your SSB log (the feed is fetched live and shown ephemerally; only your credentials are kept locally).
![SNH](https://solarnethub.com/git/oasis-fediverse.png "SolarNET.HuB")
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space.
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md)
----------
## Invite codes (for PUBs and TRIBES):
Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs).
Similarly, TRIBES (groups in Oasis) require an entry code.
![SNH](https://solarnethub.com/git/snh-oasis-invites.png "SolarNET.HuB")
While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything.
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
----------
## Architecture:
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication
that is based on the way epidemics spread.
![SNH](https://solarnethub.com/git/snh-meshnet.png "SolarNET.HuB")
This means that information is able to distribute across multiple machines, without requiring direct connections between them.
![SNH](https://solarnethub.com/git/gossip-graph1.png "SolarNET.HuB")
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
![SNH](https://solarnethub.com/git/gossip-graph2.png "SolarNET.HuB")
This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB
to reach Alice, and visa-versa.
----------
## Backend:
Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB).
SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities
of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
![SNH](https://solarnethub.com/git/ssb-participants-perspective.png "SolarNET.HuB")
In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and
eventual consistency.
----------
## Installing:
Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
----------
## Setup & Deploy:
Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
----------
## SNH-Hub (for HackLabs):
The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
![SNH](https://solarnethub.com/git/snh-pub-feed.png "SolarNET.HuB")
Just visit: https://pub.solarnethub.com/
![SNH](https://solarnethub.com/git/snh-pub-laplaza.png "SolarNET.HuB")
And also you can visit periodically the public statistic of the SNH-PUB:
![SNH](https://solarnethub.com/git/snh-pub-stats.png "SolarNET.HuB")
See stats: https://laplaza.solarnethub.com/
----------
## Roadmap:
Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
----------
## Translations:
Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
+ https://wiki.solarnethub.com/socialnet/snh#choose_language
----------
## Development:
Oasis is completely coded in: node.js, HTML5 + CSS.
Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
----------
## Links:
+ SNH Website: https://solarnethub.com
+ Kräkens.Lab: https://krakenslab.com
+ Documentation: https://wiki.solarnethub.com
+ Research: https://wiki.solarnethub.com/docs/research
+ Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
+ The KIT: https://wiki.solarnethub.com/kit/overview
+ Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
+ Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
+ Oasis: https://wiki.solarnethub.com/socialnet/overview
+ ECOin: https://wiki.solarnethub.com/ecoin/overview
+ Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
+ Warehouse: https://wiki.solarnethub.com/stock/submit_request
+ THS: https://thehackerstyle.com
+ PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
+ Youtube: https://www.youtube.com/@thehackerstyle
+ Twitch: https://twitch.tv/thehackerstyle

75
main.js Normal file
View file

@ -0,0 +1,75 @@
console.log('[Oasis Mobile] Node.js backend starting...');
process.env.HOME = process.env.HOME || require('path').resolve(__dirname, '..', '..');
process.env.ssb_appname = process.env.ssb_appname || 'ssb';
process.env.OASIS_MOBILE = '1';
process.env.CHLORIDE_JS = '1';
const path = require('path');
const fs = require('fs');
const tmpDir = path.join(process.env.HOME, 'tmp');
try { fs.mkdirSync(tmpDir, { recursive: true }); } catch (e) {}
process.env.TMPDIR = tmpDir;
process.env.TMP = tmpDir;
process.env.TEMP = tmpDir;
try { require('os').tmpdir = () => tmpDir; } catch (e) {}
try {
const dns = require('dns');
const net = require('net');
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
try { dns.setServers(servers); } catch (e) {}
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
const localResolve = (hostname, family) => {
if (hostname === 'localhost' || hostname === 'localhost.localdomain' || hostname === 'ip6-localhost') {
return family === 6 ? { address: '::1', family: 6 } : { address: '127.0.0.1', family: 4 };
}
const ipv = net.isIP(hostname);
if (ipv) return { address: hostname, family: ipv };
return null;
};
const safeLookup = function (hostname, options, callback) {
if (typeof options === 'function') { callback = options; options = {}; }
options = options || {};
const family = options.family === 6 ? 6 : 4;
const direct = localResolve(hostname, family);
if (direct) {
return process.nextTick(() => options.all
? callback(null, [direct])
: callback(null, direct.address, direct.family));
}
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
resolver.call(dns, hostname, (err, addrs) => {
if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
return callback(null, addrs[0], family);
});
};
dns.lookup = safeLookup;
if (dns.promises) {
dns.promises.lookup = (hostname, options) => new Promise((resolve, reject) => {
safeLookup(hostname, options || {}, (err, address, family) => {
if (err) return reject(err);
if (options && options.all) return resolve(address);
resolve({ address, family });
});
});
}
} catch (e) {}
const backendPath = path.join(__dirname, 'src', 'backend', 'backend.js');
console.log('[Oasis] Launching Oasis Social Network Utopia...');
console.log('[Oasis] Backend path:', backendPath);
console.log('[Oasis] HOME:', process.env.HOME);
try {
require(backendPath);
console.log('[Oasis] Backend started on port 3000');
} catch (error) {
console.error('[Oasis] Error starting backend:', error);
}

159
package.json Normal file
View file

@ -0,0 +1,159 @@
{
"name": "@krakenslab/oasis",
"version": "0.8.7",
"description": "Oasis - Social Networking Utopia",
"repository": {
"type": "git",
"url": "git+ssh://git@code.03c8.net/krakenlabs/oasis.git"
},
"license": "AGPL-3.0",
"author": "psy <epsylon@riseup.net>",
"main": "src/index.js",
"bin": {
"oasis": "npm run start"
},
"scripts": {
"start": "npm run start:ssb && sleep 10 && npm run start:backend",
"start:backend": "node ../backend/backend.js",
"start:ssb": "node SSB_server.js start &",
"dev": "npm run dev:backend",
"dev:backend": "nodemon",
"postinstall": "node ../../scripts/patch-node-modules.js"
},
"dependencies": {
"@koa/router": "^13.1.0",
"@open-rpc/client-js": "^1.8.1",
"@xenova/transformers": "^2.17.2",
"abstract-level": "^2.0.1",
"archiver": "^7.0.1",
"axios": "^1.10.0",
"cors": "^2.8.5",
"debug": "^4.3.1",
"dompurify": "^3.3.1",
"env-paths": "^2.2.1",
"epidemic-broadcast-trees": "^9.0.4",
"express": "^5.1.0",
"file-type": "^16.5.4",
"highlight.js": "11.0.0",
"hyperaxe": "^2.0.1",
"ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
"is-svg": "^4.4.0",
"jsdom": "^28.0.0",
"koa": "^2.7.0",
"koa-body": "^6.0.1",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"lodash": "^4.17.21",
"minimist": "^1.2.8",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
"multiblob": "^1.13.0",
"multiserver": "^3.3.1",
"multiserver-address": "^1.0.1",
"muxrpc": "^8.0.0",
"muxrpc-validation": "^3.0.2",
"muxrpcli": "^3.1.2",
"node-llama-cpp": "^3.10.0",
"non-private-ip": "^2.2.0",
"open": "^8.4.2",
"openpgp": "^6.3.0",
"packet-stream": "^2.0.6",
"packet-stream-codec": "^1.2.0",
"pdfjs-dist": "^5.2.133",
"pretty-ms": "^7.0.1",
"pull-abortable": "^4.1.1",
"pull-cat": "~1.1.5",
"pull-file": "^1.0.0",
"pull-many": "~1.0.6",
"pull-paramap": "^1.2.2",
"pull-pushable": "^2.2.0",
"pull-sort": "^1.0.2",
"pull-stream": "^3.7.0",
"punycode.js": "^2.3.1",
"qrcode": "^1.5.4",
"secret-stack": "^6.3.1",
"ssb-about": "^2.0.1",
"ssb-autofollow": "^1.1.0",
"ssb-backlinks": "^2.1.1",
"ssb-blobs": "^2.0.1",
"ssb-box": "^1.0.1",
"ssb-caps": "^1.0.1",
"ssb-client": "^4.9.0",
"ssb-config": "^3.4.4",
"ssb-conn": "6.0.3",
"ssb-conn-db": "^1.0.5",
"ssb-conn-hub": "^1.2.0",
"ssb-conn-staging": "^1.0.0",
"ssb-db": "^20.4.1",
"ssb-device-address": "^1.1.6",
"ssb-ebt": "^9.1.2",
"ssb-friend-pub": "^1.0.7",
"ssb-friends": "^5.0.0",
"ssb-gossip": "^1.1.1",
"ssb-invite": "^3.0.3",
"ssb-invite-client": "^1.3.3",
"ssb-keys": "^8.0.0",
"ssb-lan": "^1.0.0",
"ssb-legacy-conn": "^1.0.17",
"ssb-links": "^3.0.10",
"ssb-logging": "^1.0.0",
"ssb-markdown": "^3.6.0",
"ssb-master": "^1.0.3",
"ssb-meme": "^1.1.0",
"ssb-mentions": "^0.5.2",
"ssb-msgs": "^5.2.0",
"ssb-no-auth": "^1.0.0",
"ssb-onion": "^1.0.0",
"ssb-partial-replication": "^3.0.1",
"ssb-plugins": "^1.0.2",
"ssb-private": "^1.1.0",
"ssb-query": "^2.4.5",
"ssb-ref": "^2.16.0",
"ssb-replication-scheduler": "^3.0.0",
"ssb-search": "^1.3.0",
"ssb-server": "file:packages/ssb-server",
"ssb-tangle": "^1.0.1",
"ssb-thread-schema": "^1.1.1",
"ssb-threads": "^10.0.4",
"ssb-unix-socket": "^1.0.0",
"ssb-ws": "^6.2.3",
"util": "^0.12.5",
"yargs": "^17.7.2"
},
"overrides": {
"caller-path": "^4.0.0",
"highlight.js": "11.0.0",
"@babel/traverse": "7.23.2",
"trim": "0.0.3",
"json5": "2.2.2",
"debug": "^4.3.1",
"postcss": "8.4.31",
"punycode": "2.3.1",
"ejs": "3.1.10",
"babel-traverse": "7.0.0-alpha.1",
"ssb-conn": "6.0.3",
"ssb-ref": "^2.16.0",
"secret-stack": "^6.3.1",
"ip": "https://registry.npmjs.org/neoip/-/neoip-3.0.0.tgz",
"lodash.set": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
},
"optionalDependencies": {
"fsevents": "^2.3.2",
"sharp": "^0.33.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
},
"bugs": {
"url": "https://github.com/epsylon/oasis/issues"
},
"homepage": "https://github.com/epsylon/oasis",
"directories": {
"doc": "docs"
},
"keywords": [],
"engines": {
"node": "^10.0.0 || >=12.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

8158
src/backend/backend.js Normal file

File diff suppressed because it is too large Load diff

279
src/backend/blobHandler.js Normal file
View file

@ -0,0 +1,279 @@
const pull = require('../server/node_modules/pull-stream');
const FileType = require("../server/node_modules/file-type");
const promisesFs = require('fs').promises;
const ssb = require("../client/gui");
const config = require("../server/SSB_server").config;
const cooler = ssb({ offline: config.offline });
let sharp;
try {
sharp = require("sharp");
} catch (e) {
}
const stripImageMetadata = async (buffer) => {
if (typeof sharp !== "function") return buffer;
try {
return await sharp(buffer).rotate().toBuffer();
} catch {
return buffer;
}
};
const PDF_METADATA_KEYS = [
'/Title', '/Author', '/Subject', '/Keywords',
'/Creator', '/Producer', '/CreationDate', '/ModDate'
];
const stripPdfMetadata = (buffer) => {
try {
let str = buffer.toString('binary');
for (const key of PDF_METADATA_KEYS) {
const keyBytes = key;
const regex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*\\([^)]*\\)',
'g'
);
str = str.replace(regex, keyBytes + ' ()');
const hexRegex = new RegExp(
keyBytes.replace(/\//g, '\\/') + '\\s*<[^>]*>',
'g'
);
str = str.replace(hexRegex, keyBytes + ' <>');
}
return Buffer.from(str, 'binary');
} catch {
return buffer;
}
};
const MAX_BLOB_SIZE = 50 * 1024 * 1024;
class FileTooLargeError extends Error {
constructor(fileName, fileSize) {
super(`File too large: ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
this.name = 'FileTooLargeError';
this.fileName = fileName;
this.fileSize = fileSize;
}
}
const handleBlobUpload = async function (ctx, fileFieldName) {
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
return null;
}
const blobUpload = ctx.request.files[fileFieldName];
if (!blobUpload) return null;
let data = await promisesFs.readFile(blobUpload.filepath);
if (data.length === 0) return null;
if (data.length > MAX_BLOB_SIZE) {
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
}
const EXTENSION_MIME_MAP = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.bmp': 'image/bmp',
'.torrent': 'application/x-bittorrent'
};
const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
try {
const fileType = await FileType.fromBuffer(data);
blob.mime = (fileType && fileType.mime) ? fileType.mime : null;
} catch {
blob.mime = null;
}
if (!blob.mime && blob.name) {
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
}
if (!blob.mime) {
blob.mime = 'application/octet-stream';
}
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data);
}
const ssbClient = await cooler.open();
blob.id = await new Promise((resolve, reject) => {
pull(
pull.values([data]),
ssbClient.blobs.add((err, ref) => (err ? reject(err) : resolve(ref)))
);
});
if (blob.mime.startsWith("image/")) return `\n![image:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
return `\n[${blob.name}](${blob.id})`;
};
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
let done = false;
const finishOk = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const finishErr = (err) => {
if (done) return;
done = true;
clearTimeout(timer);
reject(err);
};
const timer = setTimeout(() => {
finishErr(new Error(`Timeout waiting for blob ${blobId}`));
}, timeoutMs);
if (!ssbClient.blobs || typeof ssbClient.blobs.has !== 'function') {
return finishErr(new Error('ssb.blobs.has is not available'));
}
ssbClient.blobs.has(blobId, (err, has) => {
if (err) return finishErr(err);
if (has) return finishOk();
if (typeof ssbClient.blobs.want !== 'function') {
return finishErr(new Error('ssb.blobs.want is not available'));
}
ssbClient.blobs.want(blobId, (err2) => {
if (err2) return finishErr(err2);
finishOk();
});
});
});
}
const serveBlob = async function (ctx) {
const encodedParam = (ctx.params.id || ctx.params.blobId || '').trim();
const raw = decodeURIComponent(encodedParam);
if (!raw) {
ctx.status = 400;
ctx.body = 'Invalid blob id';
return;
}
const blobId = raw.startsWith('&') ? raw : `&${raw}`;
const ssbClient = await cooler.open();
try {
await waitForBlob(ssbClient, blobId, 8000);
} catch (err) {
ctx.status = 504;
ctx.body = 'Blob not available';
return;
}
let buffer;
try {
buffer = await new Promise((resolve, reject) => {
pull(
ssbClient.blobs.get(blobId),
pull.collect((err, chunks) => {
if (err) return reject(err);
resolve(Buffer.concat(chunks));
})
);
});
} catch (err) {
ctx.status = 500;
ctx.body = 'Error reading blob';
return;
}
const size = buffer.length;
let mime = 'application/octet-stream';
try {
const ft = await FileType.fromBuffer(buffer);
if (ft && ft.mime) mime = ft.mime;
} catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
let head = buffer.slice(0, 512).toString('utf8');
if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
const trimmed = head.replace(/^\s+/, '').toLowerCase();
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
if (trimmed.includes('<svg')) mime = 'image/svg+xml';
}
}
const isSvg = mime === 'image/svg+xml';
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
const disposition = isSvg ? 'attachment' : 'inline';
ctx.type = mime;
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
let start = match[1] ? parseInt(match[1], 10) : 0;
let end = match[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start) || start < 0) start = 0;
if (Number.isNaN(end) || end >= size) end = size - 1;
if (start > end || start >= size) {
ctx.status = 416;
ctx.set('Content-Range', `bytes */${size}`);
return;
}
const chunk = buffer.slice(start, end + 1);
ctx.status = 206;
ctx.set('Content-Range', `bytes ${start}-${end}/${size}`);
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(chunk.length));
ctx.body = chunk;
} else {
ctx.status = 200;
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Content-Length', String(size));
ctx.body = buffer;
}
};
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };

View file

@ -0,0 +1,66 @@
const { mergeDuplicatesBy, norm } = require('./dedupe');
const identitySignature = (x) => (x && x.author && x.createdAt) ? norm(x.author) + '|' + norm(x.createdAt) : null;
const openInviteOf = (item) => {
if (!item || !Array.isArray(item.invites)) return null;
const pub = item.invites.find((inv) => inv && typeof inv === 'object' && inv.public === true && inv.code);
return pub ? { code: pub.code } : null;
};
const collabContent = (opts = {}) => {
const membersField = opts.membersField || 'members';
const undecField = opts.undecField || 'undecryptable';
const contentFields = Array.isArray(opts.contentFields) ? opts.contentFields : [];
const listFields = Array.isArray(opts.listFields) ? opts.listFields : [];
const arr = (v) => (Array.isArray(v) ? v : []);
const freshestMembers = (base, dup) => {
const bt = new Date(base.updatedAt || base.createdAt || 0).getTime();
const dt = new Date(dup.updatedAt || dup.createdAt || 0).getTime();
const fresh = dt >= bt ? dup : base;
return arr(fresh[membersField]).length ? fresh[membersField]
: (arr(base[membersField]).length ? base[membersField] : arr(dup[membersField]));
};
const mergePair = (base, dup) => {
const out = { ...base, [membersField]: freshestMembers(base, dup) };
for (const f of contentFields) out[f] = base[f] || dup[f];
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(dup[f]);
out[undecField] = !!(base[undecField] && dup[undecField]);
return out;
};
const orderForMerge = (a, b) => {
const c = new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
if (c !== 0) return c;
return ((a[undecField] || !a.title) ? 1 : 0) - ((b[undecField] || !b.title) ? 1 : 0);
};
const collapse = (items) =>
mergeDuplicatesBy(arr(items).slice().sort(orderForMerge), identitySignature, mergePair);
const fold = (base, all) => {
const sig = identitySignature(base);
if (!sig) return base;
const group = arr(all).filter((x) => identitySignature(x) === sig);
if (group.length <= 1) return base;
const canonical = collapse(group)[0];
const out = { ...base, [membersField]: canonical[membersField] };
for (const f of contentFields) out[f] = base[f] || canonical[f];
for (const f of listFields) out[f] = arr(base[f]).length ? base[f] : arr(canonical[f]);
return out;
};
const isVisible = (item, viewerId) => {
if (!item[undecField]) return true;
return item.author === viewerId || arr(item[membersField]).includes(viewerId);
};
const visibleThenCollapsed = (items, viewerId) =>
collapse(arr(items).filter((x) => isVisible(x, viewerId)));
return { identitySignature, mergePair, collapse, fold, isVisible, visibleThenCollapsed };
};
module.exports = { collabContent, identitySignature, openInviteOf };

52
src/backend/dedupe.js Normal file
View file

@ -0,0 +1,52 @@
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const dedupeBy = (items, keyFn) => {
const seen = new Set();
const out = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { out.push(it); continue; }
if (seen.has(k)) continue;
seen.add(k);
out.push(it);
}
return out;
};
const mergeDuplicatesBy = (items, keyFn, mergeFn) => {
const slots = new Map();
const out = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { out.push(it); continue; }
if (!slots.has(k)) {
const slot = { i: out.length, v: it };
slots.set(k, slot);
out.push(it);
} else {
const slot = slots.get(k);
slot.v = mergeFn(slot.v, it);
out[slot.i] = slot.v;
}
}
return out;
};
const dedupeByPreferring = (items, keyFn, scoreFn) => {
const best = new Map();
const order = [];
for (const it of (Array.isArray(items) ? items : [])) {
let k = null;
try { k = keyFn(it); } catch (_) { k = null; }
if (k == null || k === '') { order.push({ solo: it }); continue; }
let s = 0;
try { s = Number(scoreFn(it)) || 0; } catch (_) { s = 0; }
if (!best.has(k)) { const slot = { v: it, s }; best.set(k, slot); order.push({ slot }); }
else { const slot = best.get(k); if (s > slot.s) { slot.v = it; slot.s = s; } }
}
return order.map(o => (o.solo !== undefined ? o.solo : o.slot.v));
};
module.exports = { dedupeBy, mergeDuplicatesBy, dedupeByPreferring, norm };

206
src/backend/logsPdf.js Normal file
View file

@ -0,0 +1,206 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
const splitSegments = (line) => {
const segs = [];
let last = 0;
const re = new RegExp(linkPattern.source, 'g');
let m;
while ((m = re.exec(line)) !== null) {
if (m.index > last) segs.push({ t: line.slice(last, m.index), l: false });
segs.push({ t: m[0], l: true });
last = m.index + m[0].length;
}
if (last < line.length) segs.push({ t: line.slice(last), l: false });
return segs;
};
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildLogsPdf(entries, oasisId, opts = {}) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 22;
const bodyBottom = footerH + 10;
const lineH = 12;
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const allLines = [];
for (const e of entries) {
const ts = new Date(e.ts);
const when = ts.toISOString().replace('T', ' ').slice(0, 19);
allLines.push({ kind: 'header', text: `[${when}]:` });
allLines.push({ kind: 'blank', text: '' });
for (const l of wrap(e.text, 82)) allLines.push({ kind: 'text', text: l });
allLines.push({ kind: 'blank', text: '' });
}
if (!allLines.length) allLines.push({ kind: 'text', text: '(no entries)' });
const pages = [];
for (let i = 0; i < allLines.length; i += maxBodyLines) {
pages.push(allLines.slice(i, i + maxBodyLines));
}
if (!pages.length) pages.push([{ kind: 'text', text: '(no entries)' }]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const colorSpace = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${colorSpace} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
const logoX = marginX;
const logoY = pageH - headerH + 15;
parts.push(`q\n${logoW} 0 0 ${logoH} ${logoX} ${logoY} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Experience logs')}) Tj\nET`);
const inhabitantPrefix = 'Inhabitant: ';
const inhabitantPrefixW = inhabitantPrefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(inhabitantPrefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + inhabitantPrefixW} ${titleY - 16} Td\n(${escapePdf(String(oasisId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
const charW = 6;
for (const ln of pg) {
if (ln.kind === 'header') {
parts.push(`BT\n/F2 10 Tf\n1 0.647 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.text) {
const segs = splitSegments(ln.text);
let x = marginX;
for (const s of segs) {
if (!s.t) continue;
const color = s.l ? '0 0 1 rg' : '0 0 0 rg';
parts.push(`BT\n/F1 10 Tf\n${color}\n${x} ${y} Td\n(${escapePdf(s.t)}) Tj\nET`);
x += s.t.length * charW;
}
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) {
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildLogsPdf };

View file

@ -0,0 +1,126 @@
const fs = require("fs");
const path = require("path");
const FILE = path.join(__dirname, "../configs/media-favorites.json");
const DEFAULT = {
audios: [],
bookmarks: [],
calendars: [],
chats: [],
documents: [],
images: [],
maps: [],
pads: [],
shops: [],
market: [],
shopProducts: [],
torrents: [],
videos: []
};
const safeArr = (v) => (Array.isArray(v) ? v : []);
let queue = Promise.resolve();
const withLock = (fn) => {
queue = queue.then(fn, fn);
return queue;
};
const normalize = (raw) => {
const out = {};
for (const k of Object.keys(DEFAULT)) {
const list = safeArr(raw?.[k]).map((x) => String(x || "").trim()).filter(Boolean);
out[k] = Array.from(new Set(list));
}
return out;
};
const ensureFile = async () => {
try {
await fs.promises.access(FILE);
} catch (e) {
const dir = path.dirname(FILE);
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
}
};
const readAll = async () => {
await ensureFile();
try {
const txt = await fs.promises.readFile(FILE, "utf8");
return normalize(JSON.parse(txt || "{}"));
} catch (e) {
const fixed = normalize(DEFAULT);
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
return fixed;
}
};
const writeAll = async (data) => {
const dir = path.dirname(FILE);
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
const txt = JSON.stringify(normalize(data), null, 2);
await fs.promises.writeFile(tmp, txt, "utf8");
await fs.promises.rename(tmp, FILE);
};
const assertKind = (kind) => {
const k = String(kind || "").trim();
if (!Object.prototype.hasOwnProperty.call(DEFAULT, k)) throw new Error("Invalid favorites kind");
return k;
};
const lastArg = (args, n) => args[args.length - n];
const kindFromArgs = (args) => {
const k = String(lastArg(args, 1) || "").trim();
return assertKind(k);
};
const idFromArgs = (args) => String(lastArg(args, 1) || "").trim();
exports.getFavoriteSet = async (kind) => {
const k = assertKind(kind);
const data = await readAll();
return new Set(safeArr(data[k]).map(String));
};
exports.addFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.add(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.removeFavorite = async (kind, id) =>
withLock(async () => {
const k = assertKind(kind);
const favId = String(id || "").trim();
if (!favId) return;
const data = await readAll();
const set = new Set(safeArr(data[k]).map(String));
set.delete(favId);
data[k] = Array.from(set);
await writeAll(data);
});
exports.getFavoritesSet = async (...args) => exports.getFavoriteSet(kindFromArgs(args));
exports.addToFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.addFavorite(kind, id);
};
exports.removeFromFavorites = async (...args) => {
const kind = kindFromArgs(args.slice(0, -1));
const id = idFromArgs(args);
return exports.removeFavorite(kind, id);
};

20
src/backend/nameCache.js Normal file
View file

@ -0,0 +1,20 @@
const cache = new Map()
const get = (feedId) => {
if (!feedId) return null
const entry = cache.get(String(feedId))
return entry ? entry.name : null
}
const set = (feedId, name, ts) => {
if (!feedId || typeof name !== 'string' || !name) return
const id = String(feedId)
const t = Number(ts) || 0
const prev = cache.get(id)
if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t })
}
const has = (feedId) => !!feedId && cache.has(String(feedId))
const size = () => cache.size
module.exports = { get, set, has, size }

View file

@ -0,0 +1,50 @@
const positive = [
"interesting",
"necessary",
"useful",
"informative",
"wellResearched",
"accurate",
"insightful",
"actionable",
"creative",
"inspiring",
"love",
"funny",
"clear",
"uplifting"
];
const constructive = [
"unnecessary",
"rejected",
"needsSources",
"wrong",
"lowQuality",
"confusing",
"misleading",
"offTopic",
"duplicate",
"clickbait",
"propaganda"
];
const moderation = [
"spam",
"troll",
"adultOnly",
"nsfw",
"violent",
"toxic",
"harassment",
"hate",
"scam",
"triggering"
];
const all = [...positive, ...constructive, ...moderation];
all.positive = positive;
all.constructive = constructive;
all.moderation = moderation;
module.exports = all;

View file

@ -0,0 +1,139 @@
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) {
if (!text) return ''
const i18n = getI18n()
let html = String(text)
html = html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
)
.replace(/\[video:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<video controls class="post-video" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></video>`
)
.replace(/\[audio:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, _name, blob) =>
`<audio controls class="post-audio" src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}"></audio>`
)
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
)
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
)
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
)
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
)
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
)
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
)
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
}
module.exports = { renderTextWithStyles, renderTextPreview }

92
src/backend/renderUrl.js Normal file
View file

@ -0,0 +1,92 @@
const { a, img, video, audio } = require("../server/node_modules/hyperaxe");
const i18nBase = require("../client/assets/translations/i18n");
function getI18n() {
try {
const { i18n } = require("../views/main_views");
return i18n;
} catch (_) {
return i18nBase['en'] || {};
}
}
function renderUrl(text) {
if (typeof text !== 'string') return [text];
const blobImageRegex = /!\[([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobVideoRegex = /\[video:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobAudioRegex = /\[audio:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const blobPdfRegex = /\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g;
const mdMentionRegex = /\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g;
const rawMentionRegex = /@([A-Za-z0-9+/=.\-]+\.ed25519)/g;
const urlRegex = /\b(?:https?:\/\/|www\.)[^\s]+/g;
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/gi;
const allMatches = [];
for (const m of text.matchAll(blobImageRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-image', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobVideoRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-video', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobAudioRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-audio', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(blobPdfRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'blob-pdf', name: m[1], blob: m[2] });
}
for (const m of text.matchAll(mdMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'md-mention', name: m[1], feedId: m[2] });
}
for (const m of text.matchAll(rawMentionRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'raw-mention', feedId: m[1] });
}
for (const m of text.matchAll(urlRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'url', text: m[0] });
}
for (const m of text.matchAll(emailRegex)) {
allMatches.push({ index: m.index, length: m[0].length, type: 'email', text: m[0] });
}
allMatches.sort((a, b) => a.index - b.index);
const filtered = [];
let lastEnd = 0;
for (const m of allMatches) {
if (m.index < lastEnd) continue;
filtered.push(m);
lastEnd = m.index + m.length;
}
const result = [];
let cursor = 0;
for (const m of filtered) {
if (cursor < m.index) {
result.push(text.slice(cursor, m.index));
}
if (m.type === 'blob-image') {
result.push(img({ src: `/blob/${encodeURIComponent(m.blob)}`, alt: m.name || '', class: 'post-image' }));
} else if (m.type === 'blob-video') {
result.push(video({ controls: true, class: 'post-video', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-audio') {
result.push(audio({ controls: true, class: 'post-audio', src: `/blob/${encodeURIComponent(m.blob)}` }));
} else if (m.type === 'blob-pdf') {
const i18n = getI18n();
const label = m.name || i18n.pdfFallbackLabel || 'PDF';
result.push(a({ href: `/blob/${encodeURIComponent(m.blob)}`, class: 'post-pdf', target: '_blank' }, label));
} else if (m.type === 'md-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.name));
} else if (m.type === 'raw-mention') {
const feedWithAt = '@' + m.feedId;
result.push(a({ href: `/author/${encodeURIComponent(feedWithAt)}`, class: 'mention' }, '@' + m.feedId.slice(0, 8) + '...'));
} else if (m.type === 'url') {
const href = m.text.startsWith('http') ? m.text : `https://${m.text}`;
result.push(a({ href, target: '_blank', rel: 'noopener noreferrer' }, m.text));
} else if (m.type === 'email') {
result.push(a({ href: `mailto:${m.text}` }, m.text));
}
cursor = m.index + m.length;
}
if (cursor < text.length) {
result.push(text.slice(cursor));
}
return result;
}
module.exports = { renderUrl };

View file

@ -0,0 +1,45 @@
"use strict";
const { JSDOM } = require('../server/node_modules/jsdom');
const DOMPurify = require('../server/node_modules/dompurify');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const stripDangerousTags = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br',
'b', 'strong', 'i', 'em', 'u',
'ul', 'ol', 'li',
'blockquote',
'code', 'pre'
],
ALLOWED_ATTR: [],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script'],
FORBID_ATTR: ['style']
});
};
const sanitizeHtml = (input) => {
if (typeof input !== 'string') return '';
return purify.sanitize(input, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br', 'hr',
'b', 'strong', 'i', 'em', 'u', 's', 'del',
'ul', 'ol', 'li',
'blockquote', 'code', 'pre',
'a', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'img', 'video', 'audio',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel', 'src', 'alt', 'title', 'controls'],
FORBID_TAGS: ['svg', 'math', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select', 'button', 'script', 'style', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onsubmit', 'onchange', 'style']
});
};
module.exports = { stripDangerousTags, sanitizeHtml };

View file

@ -0,0 +1,228 @@
const fs = require('fs');
const path = require('path');
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const wrap = (txt, max = 82) => {
const out = [];
for (const raw of String(txt || '').split('\n')) {
let line = raw;
while (line.length > max) {
let cut = line.lastIndexOf(' ', max);
if (cut <= 0) cut = max;
out.push(line.slice(0, cut));
line = line.slice(cut).replace(/^\s+/, '');
}
out.push(line);
}
return out;
};
const readJpegDims = (buf) => {
let i = 2;
while (i < buf.length) {
if (buf[i] !== 0xFF) return null;
const marker = buf[i + 1];
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
const len = buf.readUInt16BE(i + 2);
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
const h = buf.readUInt16BE(i + 5);
const w = buf.readUInt16BE(i + 7);
const c = buf[i + 9];
return { w, h, c };
}
i += 2 + len;
}
return null;
};
function buildSmartContractPdf({ transfer, block, viewerId }) {
const pageW = 612;
const pageH = 792;
const marginX = 50;
const headerH = 90;
const footerH = 40;
const bodyTop = pageH - headerH - 24;
const bodyBottom = footerH + 10;
const lineH = 14;
let logoBuf = null;
let logoDims = null;
try {
logoBuf = fs.readFileSync(LOGO_PATH);
logoDims = readJpegDims(logoBuf);
} catch (_) {}
const t = transfer || {};
const b = block || {};
const fmt = v => (v === undefined || v === null) ? '' : String(v);
const fmtAmount = () => {
const cat = String(t.category || 'ECONOMIC').toUpperCase();
const unit = cat === 'TIME' ? 'h' : cat === 'TRUST' ? 'trust' : 'ECO';
return `${Number(t.amount || 0).toFixed(6)} ${unit}`;
};
const fmtDate = v => v ? new Date(v).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '';
const confirmedBy = Array.isArray(t.confirmedBy) ? t.confirmedBy : [];
const required = t.from === t.to ? 1 : 2;
const confirmedCount = confirmedBy.length;
const tags = Array.isArray(t.tags) ? t.tags.join(', ') : '';
const sections = [];
sections.push({ kind: 'title', text: `Concept: ${t.concept || '-'}` });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'OASIS IDs' });
sections.push({ kind: 'kv', label: 'From', value: fmt(t.from) });
sections.push({ kind: 'kv', label: 'To', value: fmt(t.to) });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'TERMS' });
sections.push({ kind: 'kv', label: 'Category', value: String(t.category || 'ECONOMIC').toUpperCase() });
if (String(t.category || '').toUpperCase() !== 'TRUST') sections.push({ kind: 'kv', label: 'Amount', value: fmtAmount() });
sections.push({ kind: 'kv', label: 'Status', value: fmt(t.status) });
if (t.deadline) sections.push({ kind: 'kv', label: 'Deadline', value: fmtDate(t.deadline) });
if (tags) sections.push({ kind: 'kv', label: 'Tags', value: tags });
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'CONFIRMATIONS' });
sections.push({ kind: 'kv', label: 'Required', value: String(required) });
sections.push({ kind: 'kv', label: 'Confirmed', value: String(confirmedCount) });
if (confirmedBy.length) {
for (const f of confirmedBy) sections.push({ kind: 'kv', label: 'Signed by', value: fmt(f) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'BLOCK VERIFICATION' });
if (b && b.id) {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(b.id) });
if (b.ts) sections.push({ kind: 'kv', label: 'Block Timestamp', value: fmtDate(b.ts) });
if (b.type) sections.push({ kind: 'kv', label: 'Block Type', value: String(b.type).toUpperCase() });
if (b.author) sections.push({ kind: 'kv', label: 'Block Author', value: fmt(b.author) });
if (b.size) sections.push({ kind: 'kv', label: 'Block Size', value: `${b.size} bytes` });
} else {
sections.push({ kind: 'kv', label: 'Block ID', value: fmt(t.id) });
}
sections.push({ kind: 'blank' });
sections.push({ kind: 'section', text: 'METADATA' });
sections.push({ kind: 'kv', label: 'Transfer ID', value: fmt(t.id) });
if (t.createdAt) sections.push({ kind: 'kv', label: 'Created At', value: fmtDate(t.createdAt) });
if (t.updatedAt) sections.push({ kind: 'kv', label: 'Updated At', value: fmtDate(t.updatedAt) });
const lines = [];
for (const s of sections) {
if (s.kind === 'kv') {
const txt = `${s.label}: ${s.value}`;
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
} else {
lines.push(s);
}
}
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
const pages = [];
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
if (!pages.length) pages.push([]);
const objects = [];
const addObj = body => { objects.push(body); return objects.length; };
const catalogId = addObj(null);
const pagesId = addObj(null);
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
let logoXObjId = null;
if (logoBuf && logoDims) {
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
logoXObjId = addObj({ dict, stream: logoBuf });
}
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const footerLeft = `Generated: ${exportDate}`;
const pageIds = [];
const contentIds = [];
pages.forEach((pg, pgIdx) => {
const parts = [];
if (logoXObjId) {
const logoH = 60;
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
}
const titleX = (logoXObjId ? marginX + 80 : marginX);
const titleY = pageH - 45;
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf('OASIS - Smart Contract')}) Tj\nET`);
const prefix = 'Issued to: ';
const prefixW = prefix.length * 5.4;
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(viewerId || ''))}) Tj\nET`);
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
let y = bodyTop;
for (const ln of pg) {
if (ln.kind === 'title') {
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'subtitle') {
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
} else if (ln.kind === 'section') {
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
} else if (ln.kind === 'kv') {
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
}
y -= lineH;
}
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
const pageLabelW = pageLabel.length * 4.8;
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
const content = parts.join('\n');
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
const cid = addObj(stream);
contentIds.push(cid);
const pid = addObj(null);
pageIds.push(pid);
});
for (let i = 0; i < pageIds.length; i++) {
const resources = logoXObjId
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
}
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
const chunks = [];
const offsets = [0];
let byteLen = 0;
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
for (let i = 0; i < objects.length; i++) {
offsets.push(byteLen);
const obj = objects[i];
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
push(obj.stream);
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
} else {
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
}
}
const xrefStart = byteLen;
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
push(Buffer.from(xref, 'binary'));
return Buffer.concat(chunks);
}
module.exports = { buildSmartContractPdf };

121
src/backend/updater.js Normal file
View file

@ -0,0 +1,121 @@
const fetch = require('../server/node_modules/node-fetch');
const { existsSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
const { join } = require('path');
const localpackage = join(__dirname, '../server/package.json');
const remoteUrl = 'https://code.03c8.net/KrakensLab/oasis/raw/master/src/server/package.json'; // Official SNH-Oasis
const remoteUrl2 = 'https://raw.githubusercontent.com/epsylon/oasis/refs/heads/main/src/server/package.json'; // Mirror SNH-Oasis
let printed = false;
async function extractVersionFromText(text) {
try {
const versionMatch = text.match(/"version":\s*"([^"]+)"/);
if (versionMatch) {
return versionMatch[1];
} else {
throw new Error('Version not found in the response.');
}
} catch (error) {
console.error("Error extracting version:", error.message);
return null;
}
}
async function diffVersion(body, callback) {
try {
const remoteData = JSON.parse(body);
const remoteVersion = remoteData.version;
const localData = JSON.parse(readFileSync(localpackage, 'utf8'));
const localVersion = localData.version;
const updateFlagPath = join(__dirname, "../server/.update_required");
if (remoteVersion !== localVersion) {
writeFileSync(updateFlagPath, JSON.stringify({ required: true }));
callback("required");
} else {
if (existsSync(updateFlagPath)) unlinkSync(updateFlagPath);
callback(""); // no updates required
}
} catch (error) {
console.error("Error comparing versions:", error.message);
callback("error");
}
}
async function checkMirror(callback) {
try {
const response = await fetch(remoteUrl2, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://raw.githubusercontent.com',
'Origin': 'https://raw.githubusercontent.com'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
callback(null, data);
} catch (error) {
console.error("\noasis@version: no updates requested.\n");
callback(error);
}
}
exports.getRemoteVersion = async () => {
if (existsSync('../../.git')) {
try {
const response = await fetch(remoteUrl, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Referer': 'https://code.03c8.net',
'Origin': 'https://code.03c8.net'
}
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.text();
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else if (status === "") {
console.log("\noasis@version: no updates requested.\n");
}
});
} catch (error) {
checkMirror((err, data) => {
if (err) {
console.error("\noasis@version: no updates requested.\n");
} else {
diffVersion(data, (status) => {
if (status === "required" && !printed) {
printed = true;
console.log("\noasis@version: new code updates are available!\n\n1) Run Oasis and go to 'Settings' tab\n2) Click at 'Get updates' button to download latest code\n3) Restart Oasis when finished\n");
} else {
console.log("oasis@version: no updates requested.\n");
}
});
}
});
}
}
};

116
src/backend/vote_tally.js Normal file
View file

@ -0,0 +1,116 @@
const buildVoteTally = (messages) => {
const nodes = new Map();
const ballots = [];
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'votesVote') { if (c.target) ballots.push({ target: c.target, author: v.author, choice: c.choice }); continue; }
if (c.type !== 'votes') continue;
nodes.set(m.key, { key: m.key, author: v.author, content: c, ts: v.timestamp || m.timestamp || 0 });
}
const prev = new Map();
for (const [k, n] of nodes) {
const t = n.content.replaces;
if (typeof t === 'string' && nodes.has(t)) prev.set(k, t);
}
const childrenOf = new Map();
for (const [k, p] of prev) {
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p).push(k);
}
const rootOf = (k) => { let x = k, g = 0; while (prev.has(x) && g++ < 100000) x = prev.get(x); return x; };
const groups = new Map();
for (const k of nodes.keys()) { const r = rootOf(k); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(k); }
const tallyByKey = new Map();
for (const [root, keys] of groups.entries()) {
const rootNode = nodes.get(root);
if (!rootNode) continue;
const options = Array.isArray(rootNode.content.options) ? rootNode.content.options : [];
let votesMap = Object.assign({}, rootNode.content.votes || {});
let voters = Array.isArray(rootNode.content.voters) ? rootNode.content.voters.slice() : [];
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
let cur = root;
let g = 0;
let creatorChoice = null;
let creatorCounted = false;
while (g++ < 100000) {
const kids = (childrenOf.get(cur) || []).map(k => nodes.get(k)).filter(Boolean).sort((a, b) => a.ts - b.ts);
if (!kids.length) break;
let advanced = false;
for (const kid of kids) {
const kVotes = Object.assign({}, kid.content.votes || {});
const kVoters = Array.isArray(kid.content.voters) ? kid.content.voters.slice() : [];
for (const o of options) if (!(o in kVotes)) kVotes[o] = 0;
if (kid.author === rootNode.author) {
if (!creatorCounted && kVoters.includes(rootNode.author) && !voters.includes(rootNode.author)) {
creatorCounted = true;
let inc = null;
let incCount = 0;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d >= 1) { inc = o; incCount++; }
}
if (incCount === 1) creatorChoice = inc;
}
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (voters.includes(kid.author)) continue;
if (kVoters.length !== voters.length + 1) continue;
const added = kVoters.filter(x => !voters.includes(x));
if (added.length !== 1 || added[0] !== kid.author) continue;
let deltas = 0;
let deltaOk = true;
for (const o of options) {
const d = (Number(kVotes[o]) || 0) - (Number(votesMap[o]) || 0);
if (d === 0) continue;
if (d === 1) { deltas++; continue; }
deltaOk = false;
break;
}
if (!deltaOk || deltas !== 1) continue;
votesMap = kVotes;
voters = kVoters;
cur = kid.key;
advanced = true;
break;
}
if (!advanced) break;
}
if (voters.includes(rootNode.author)) {
voters = voters.filter(x => x !== rootNode.author);
if (creatorChoice && (Number(votesMap[creatorChoice]) || 0) > 0) votesMap[creatorChoice] = votesMap[creatorChoice] - 1;
}
const voterSet = new Set(voters);
let totalVotes = voters.length;
const chainKeys = new Set(keys);
for (const b of ballots) {
if (!chainKeys.has(b.target)) continue;
if (b.author === rootNode.author) continue;
if (!b.author || voterSet.has(b.author)) continue;
if (!options.includes(b.choice)) continue;
voterSet.add(b.author);
voters.push(b.author);
votesMap[b.choice] = (votesMap[b.choice] || 0) + 1;
totalVotes += 1;
}
const tally = { votes: votesMap, voters, totalVotes };
for (const k of keys) tallyByKey.set(k, tally);
}
return tallyByKey;
};
module.exports = { buildVoteTally };

View file

@ -0,0 +1,17 @@
const fs = require("fs");
const path = require("path");
const STORAGE_DIR = path.join(__dirname, "..", "configs");
const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
function ensure() {
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
}
function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
module.exports = { getAddress, setAddress };

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<title>oasis favicon</title>
<text x="0" y="14">🏝️</text>
</svg>

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View file

@ -0,0 +1,92 @@
{
"academia": {
"name": "ACADEMIA",
"short": "aca",
"motto": "Nosce te ipsum…",
"roles": "Educators, teachers, coordinators",
"function": "Education, understanding, talent discovery, selection process",
"description": "All inhabitants start here. ACADEMIA receives newcomers, provides information on the simulation mechanics, presents each house's objectives and runs the selection process.",
"month": 1,
"image": "/assets/larp/images/academia.jpg"
},
"solaris": {
"name": "SolarIS",
"short": "sol",
"motto": "The word makes us human. Violence, beasts.",
"roles": "Rulers, lawyers, diplomats",
"function": "Governance, law, dialogue, politics, communication",
"description": "Holds the political voice. Mediates disputes, drafts norms, builds coalitions through dialogue and persuasion.",
"month": 2,
"image": "/assets/larp/images/solaris.jpg"
},
"arrakis": {
"name": "ARRakis",
"short": "arr",
"motto": "How long do you say that it must be resolved?",
"roles": "Engineers, scientists, technicians",
"function": "Technology, invention, problem-solving, building and maintaining",
"description": "The makers. They keep the machinery running, prototype, repair, automate; favour pragmatic solutions over endless debate.",
"month": 3,
"image": "/assets/larp/images/arrakis.jpg"
},
"terraverde": {
"name": "TERRA.VErDE",
"short": "ter",
"motto": "Love nature, universe and being, because we are all one.",
"roles": "Farmers, ecologists, doctors, nutritionists",
"function": "Healthcare, food, nature, resources, climate management",
"description": "Stewards of the living systems. Manage food, health and the relationship with the biosphere; long timescales, regenerative principles.",
"month": 4,
"image": "/assets/larp/images/terraverde.jpg"
},
"unsystem": {
"name": "UNSYSTem",
"short": "uns",
"motto": "We do not make statements!",
"roles": "Chaos agents, trolls, punks",
"function": "Organization of disorder, tactical chaos",
"description": "Holds the right to question, sabotage, provoke. Resists capture by any other house and keeps the system uncomfortable.",
"month": 5,
"image": "/assets/larp/images/unsystem.jpg"
},
"dogma": {
"name": "DogmA",
"short": "dog",
"motto": "The question is not when to do it, the question is how.",
"roles": "Thinkers, journalists, philosophers",
"function": "Information control, knowledge, philosophy, memory, AI",
"description": "The archive and the discourse. Curates knowledge, drafts narratives, governs how information is produced and propagated.",
"month": 6,
"image": "/assets/larp/images/dogma.jpg"
},
"helix": {
"name": "HeliX",
"short": "hlx",
"motto": "If you do not smile, it's not my revolution.",
"roles": "Clowns, influencers, musicians, priests",
"function": "Entertainment, culture, humor, celebration, human relationships",
"description": "Carries culture, ritual and joy. Holds the social fabric together through art, festival and shared emotion.",
"month": 7,
"image": "/assets/larp/images/helix.jpg"
},
"quark": {
"name": "QuarK",
"short": "quk",
"motto": "Without security, there is no freedom!",
"roles": "Athletes, soldiers, street people",
"function": "Protection, security, defense, survival, family",
"description": "The guard. Trains, defends, organises mutual aid; demands clear loyalties and clear lines.",
"month": 8,
"image": "/assets/larp/images/quark.jpg"
},
"hermandad": {
"name": "HERmanDAD",
"short": "hrm",
"motto": "Grow. Build. Expand!",
"roles": "Architects, builders, investors, industrialists",
"function": "Construction, development, sustainability, logistics, production",
"description": "The builders. Plan large works, organise production lines and supply chains; measure success in things shipped and capacity created.",
"month": 9,
"image": "/assets/larp/images/hermandad.jpg"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,67 @@
.hljs-comment,
.hljs-quote {
color: var(--base03);
}
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: var(--base08);
}
.hljs-number,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: var(--base09);
}
.hljs-attribute {
color: var(--base0A);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: var(--base0B);
}
.hljs-title,
.hljs-section {
color: var(--base0D);
}
.hljs-keyword,
.hljs-selector-tag {
color: var(--base0E);
}
.hljs {
display: block;
overflow-x: auto;
color: var(--base05);
padding: 0.5em;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: bold;
}
.hljs-string, .hljs-link {
background: #FFA500;
user-select: text;
}

View file

@ -0,0 +1,916 @@
html {
-webkit-text-size-adjust: 100%;
box-sizing: border-box;
overflow-x: hidden !important;
}
*, *::before, *::after {
box-sizing: inherit;
}
body {
font-size: 15px;
overflow-x: hidden;
max-width: 100vw;
}
img,
video,
canvas,
iframe,
table {
max-width: 100% !important;
}
pre,
code {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.header {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
padding: 4px !important;
gap: 4px !important;
overflow: visible !important;
}
.header-content {
display: flex !important;
flex-wrap: wrap !important;
width: 100% !important;
max-width: 100% !important;
padding: 2px !important;
gap: 4px !important;
overflow: visible !important;
}
.top-bar-left,
.top-bar-mid,
.top-bar-right {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
align-items: stretch !important;
gap: 6px !important;
}
.top-bar-left nav ul,
.top-bar-mid nav ul,
.top-bar-right nav ul {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
padding: 0 !important;
margin: 0 !important;
overflow: visible !important;
}
.top-bar-left nav ul li,
.top-bar-mid nav ul li,
.top-bar-right nav ul li {
margin: 0 !important;
}
.top-bar-left nav ul li a,
.top-bar-mid nav ul li a,
.top-bar-right nav ul li a {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 8px !important;
font-size: 0.9rem !important;
white-space: nowrap !important;
}
.search-input,
.feed-search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 40px !important;
font-size: 16px !important;
}
.main-content {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 12px !important;
}
.sidebar-left,
.sidebar-right,
.main-column {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding: 10px !important;
border-left: none !important;
border-right: none !important;
}
.sidebar-left {
order: 1 !important;
}
.main-column {
order: 3 !important;
}
.sidebar-right {
order: 2 !important;
}
.sidebar-left nav ul,
.sidebar-right nav ul {
display: flex !important;
flex-direction: column !important;
}
.oasis-nav-header {
font-size: 0.85rem !important;
}
.oasis-nav-list li a {
font-size: 0.9rem !important;
padding: 8px 12px !important;
}
button,
input[type="submit"],
input[type="button"],
.filter-btn,
.create-button,
.edit-btn,
.delete-btn,
.join-btn,
.leave-btn,
.buy-btn {
min-height: 44px !important;
font-size: 16px !important;
white-space: normal !important;
text-align: center !important;
}
.feed-row,
.comment-body-row,
table {
display: block !important;
width: 100% !important;
overflow-x: auto !important;
}
textarea,
input:not([type="checkbox"]):not([type="radio"]),
select {
width: 100% !important;
max-width: 100% !important;
font-size: 16px !important;
}
input[type="checkbox"]:not(.oasis-nav-toggle):not(.oasis-nav-side-toggle),
input[type="radio"] {
width: auto !important;
max-width: none !important;
flex: none !important;
}
.gallery {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 8px !important;
}
footer,
.footer {
display: block !important;
width: 100% !important;
max-width: 100% !important;
padding: 12px !important;
overflow-x: auto !important;
}
footer div {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
}
h1 { font-size: 1.35em !important; }
h2 { font-size: 1.2em !important; }
h3 { font-size: 1em !important; }
.small,
.time {
font-size: 0.8rem !important;
}
.created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.5 !important;
font-size: 0.8rem !important;
}
.header-content .created-at {
display: block !important;
flex: 1 1 100% !important;
min-width: 0 !important;
max-width: 100% !important;
}
.post-meta,
.feed-post-meta,
.feed-row .small,
.feed-row .time,
.feed-row .created-at {
display: block !important;
width: 100% !important;
white-space: normal !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
line-height: 1.4 !important;
}
.post-meta,
.feed-post-meta {
flex-direction: column !important;
gap: 4px !important;
}
.mode-buttons,
.mode-buttons-cols,
.mode-buttons-row,
.filter-group,
.activity-filter-grid,
.activity-sub-filter,
.filters .ui-toolbar,
.filter-group,
.inhabitant-action,
.mode-buttons {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
width: 100% !important;
max-width: 100% !important;
gap: 6px !important;
overflow-x: hidden !important;
scroll-snap-type: none !important;
grid-template-columns: none !important;
}
.mode-buttons .column,
.mode-buttons > div,
.activity-filter-col {
display: contents !important;
}
.mode-buttons form,
.filter-group form,
.inhabitant-action form,
.activity-filter-col form {
width: auto !important;
flex: 0 1 auto !important;
scroll-snap-align: none !important;
margin: 0 !important;
}
.inhabitant-action .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
white-space: nowrap !important;
}
.profile-sensors-box {
display: flex !important;
flex-direction: column !important;
align-items: flex-start !important;
gap: 6px !important;
width: 100% !important;
max-width: 100% !important;
}
.profile-sensors-box > * {
max-width: 100% !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
}
.inhabitant-activity-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 6px !important;
}
.activity-filter-grid .filter-btn,
.activity-filter-col .filter-btn,
.activity-sub-filter .filter-btn {
min-height: 0 !important;
padding: 7px 12px !important;
font-size: 13px !important;
white-space: nowrap !important;
}
.mode-buttons .filter-btn,
.mode-buttons button,
.filter-group .filter-btn,
.filter-group button,
.activity-filter-grid .filter-btn,
.activity-filter-grid button,
.activity-sub-filter .filter-btn,
.filters .filter-btn,
.filters button {
width: auto !important;
flex: 0 0 auto !important;
white-space: nowrap !important;
min-width: 0 !important;
}
.inhabitant-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: visible !important;
}
.inhabitant-left {
width: 100% !important;
text-align: center !important;
flex: 0 0 auto !important;
height: auto !important;
}
.inhabitant-details {
width: 100% !important;
}
.inhabitant-left > a:first-of-type,
.inhabitant-left > a:first-child {
display: inline-block !important;
width: 150px !important;
height: 150px !important;
max-width: 150px !important;
margin: 0 auto 12px !important;
}
.inhabitant-photo,
.inhabitant-photo-details {
width: 150px !important;
height: 150px !important;
max-width: 150px !important;
object-fit: cover !important;
border-radius: 50% !important;
display: block !important;
margin: 0 auto !important;
}
.inhabitants-list,
.inhabitants-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
overflow: hidden !important;
}
.tribe-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
grid-template-columns: 1fr !important;
}
.tribes-list,
.tribes-grid {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
.tribe-card-image {
width: 100% !important;
max-width: 100% !important;
}
.tribe-section-nav {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
gap: 6px !important;
overflow-x: visible !important;
width: 100% !important;
max-width: 100% !important;
}
.tribe-section-group {
display: contents !important;
}
.tribe-section-btn {
font-size: 12px !important;
padding: 7px 12px !important;
white-space: nowrap !important;
flex: 0 0 auto !important;
}
.tribe-details {
flex-direction: column !important;
}
.tribe-side {
width: 100% !important;
}
.tribe-main {
width: 100% !important;
}
.tribe-content-grid {
grid-template-columns: 1fr !important;
}
.tribe-inhabitants-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-media-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.tribe-overview-grid {
grid-template-columns: 1fr !important;
}
.tribe-mode-buttons {
flex-wrap: wrap !important;
}
.tribe-card-hero-image {
height: 180px !important;
}
.tribe-votation-option {
flex-direction: column !important;
align-items: flex-start !important;
}
.tribe-content-header {
flex-direction: column !important;
gap: 8px !important;
}
.tribe-content-filters {
flex-wrap: wrap !important;
}
.tribe-media-filters {
flex-wrap: wrap !important;
}
.forum-card {
flex-direction: column !important;
}
.forum-score-col,
.forum-main-col,
.root-vote-col {
width: 100% !important;
}
.forum-header-row {
flex-direction: column !important;
gap: 4px !important;
}
.forum-meta {
flex-wrap: wrap !important;
gap: 8px !important;
}
.forum-thread-header {
flex-direction: column !important;
}
.forum-comment {
margin-left: 0 !important;
padding-left: 8px !important;
}
.comment-body-row {
flex-direction: column !important;
}
.comment-vote-col,
.comment-text-col {
width: 100% !important;
}
.forum-score-box,
.forum-score-form {
flex-direction: row !important;
justify-content: center !important;
}
.new-message-form textarea,
.comment-textarea {
width: 100% !important;
}
[style*="grid-template-columns: repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(6"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(3"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns: repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="grid-template-columns:repeat(auto-fit"] {
grid-template-columns: 1fr !important;
}
[style*="width:50%"] {
width: 100% !important;
}
.inhabitant-karma-ubi .ubi-line{display:block;font-size:0.9em}
.pad-editor-container{flex-direction:column !important}
.pad-editor-area,.pad-members-list{width:100% !important;min-width:unset !important}
.chat-main-split{flex-direction:column !important}
.chat-messages-column,.chat-participants-column{width:100% !important;min-width:unset !important;flex:unset !important}
.games-grid{grid-template-columns:1fr !important}
.game-card{width:100% !important}
.main-column [class*="-row"],
.main-column [class*="-card"],
.main-column [class*="-item"],
.main-column [class*="-entry"],
.main-column .card,
.main-column article {
flex-wrap: wrap !important;
min-width: 0 !important;
}
.main-column [class*="-row"] > *,
.main-column [class*="-card"] > * {
min-width: 0 !important;
}
button:not([class]),
input[type="submit"]:not([class]),
input[type="button"]:not([class]),
.filter-btn,
a.filter-btn,
.conn-actions button,
.peers-conn-actions button {
background-color: #FFA500 !important;
color: #1a1a1a !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 14px !important;
min-height: 42px !important;
padding: 9px 14px !important;
text-align: center !important;
white-space: normal !important;
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.filter-btn.active {
background-color: #FFD700 !important;
border-color: #FFD700 !important;
}
.audios-search,
.videos-search,
.images-search {
width: 100% !important;
max-width: 100% !important;
margin: 8px 0 !important;
}
.filter-box {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
max-width: 100% !important;
gap: 8px !important;
align-items: stretch !important;
}
.filter-box__input {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
height: 42px !important;
font-size: 16px !important;
box-sizing: border-box !important;
}
.filter-box__controls {
display: flex !important;
flex-direction: row !important;
flex-wrap: wrap !important;
width: 100% !important;
gap: 8px !important;
align-items: stretch !important;
}
.filter-box__controls .transfer-range {
display: flex !important;
flex-direction: row !important;
flex: 1 1 100% !important;
width: 100% !important;
gap: 8px !important;
}
.filter-box__number {
flex: 1 1 0 !important;
min-width: 0 !important;
width: auto !important;
height: 42px !important;
font-size: 15px !important;
box-sizing: border-box !important;
}
.filter-box__select {
flex: 1 1 auto !important;
min-width: 0 !important;
height: 42px !important;
font-size: 15px !important;
}
.filter-box__button {
flex: 0 0 auto !important;
width: auto !important;
min-height: 42px !important;
white-space: nowrap !important;
background-color: #FFA500 !important;
color: #1a1a1a !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
font-weight: 600 !important;
padding: 9px 16px !important;
}
.filters .ui-toolbar--filters {
justify-content: center !important;
align-items: center !important;
gap: 6px !important;
}
.main-column button,
.main-column input,
.main-column select,
.main-column textarea,
.main-column .btn,
.main-column .create-button,
.main-column .update-btn,
.main-column .delete-btn,
.main-column .edit-btn,
.main-column .join-btn,
.main-column .leave-btn,
.main-column .buy-btn,
.main-column .submit-button {
max-width: 100% !important;
box-sizing: border-box !important;
overflow-wrap: anywhere !important;
}
.main-column button,
.main-column .btn,
.main-column .create-button,
.main-column .update-btn,
.main-column .delete-btn,
.main-column .submit-button {
white-space: normal !important;
}
.main-column form {
max-width: 100% !important;
min-width: 0 !important;
}
.card-field {
display: flex !important;
flex-wrap: wrap !important;
min-width: 0 !important;
max-width: 100% !important;
gap: 4px !important;
}
.card-field .card-value,
.card-field .card-label {
min-width: 0 !important;
max-width: 100% !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
word-break: break-word !important;
}
.card-value .user-link,
.card-value a {
white-space: normal !important;
max-width: 100% !important;
overflow-wrap: anywhere !important;
}
.market-grid,
.shop-products-grid,
.jobs-grid {
display: grid !important;
grid-template-columns: 1fr !important;
gap: 12px !important;
width: 100% !important;
max-width: 100% !important;
}
.market-card,
.market-item {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
}
.cycle-info {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 8px !important;
}
.kpi {
min-width: 0 !important;
padding: 8px !important;
}
.kpi__label {
letter-spacing: 0 !important;
word-break: normal !important;
overflow-wrap: break-word !important;
font-size: 11px !important;
}
.kpi__value {
word-break: break-word !important;
overflow-wrap: anywhere !important;
font-size: 13px !important;
}
.table-wrap {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.table-wrap table,
.table-wrap .table {
display: table !important;
width: 100% !important;
max-width: 100% !important;
table-layout: fixed !important;
}
.table-wrap th,
.table-wrap td {
white-space: normal !important;
word-break: normal !important;
overflow-wrap: break-word !important;
font-size: 10px !important;
padding: 4px 3px !important;
text-align: center !important;
}
.search-filters-row {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.search-filters-table,
.search-filters-table tbody,
.search-filters-table tr,
.search-filters-table td {
display: block !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
}
.search-filters-table td {
padding: 4px 0 !important;
}
.search-filters-table input,
.search-filters-table select,
.search-oasis-id {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
margin-left: 0 !important;
}
.oasis-id-copy {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
font-family: monospace !important;
font-size: 12px !important;
text-align: center !important;
padding: 8px !important;
border: 1px solid #FFA500 !important;
border-radius: 8px !important;
background: rgba(255, 165, 0, 0.08) !important;
color: inherit !important;
-webkit-user-select: all !important;
user-select: all !important;
}
.peer-key,
.user-link.peer-key {
-webkit-user-select: all !important;
user-select: all !important;
word-break: break-all !important;
}
.create-button,
button.create-button,
a.create-button {
background-color: #ffcc00 !important;
border-color: #e6b800 !important;
color: #000 !important;
}
.peers-list .block-info-table,
.peers-technical-block .block-info-table,
.gov-overview {
display: block !important;
width: 100% !important;
max-width: 100% !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.peers-list .block-info-table tbody,
.peers-technical-block .block-info-table tbody,
.gov-overview tbody {
display: block !important;
width: 100% !important;
}
.gov-overview thead { display: none !important; }
.peers-list .block-info-table tr:first-child,
.peers-technical-block .block-info-table tr:first-child {
display: none !important;
}
.peers-list .block-info-table tr,
.peers-technical-block .block-info-table tr,
.gov-overview tbody tr {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
border: 1px solid rgba(255, 165, 0, 0.35) !important;
border-radius: 8px !important;
margin: 0 0 8px 0 !important;
padding: 6px 10px !important;
background: rgba(255, 165, 0, 0.04) !important;
}
.peers-list .block-info-table td,
.peers-technical-block .block-info-table td,
.gov-overview tbody td {
display: flex !important;
justify-content: space-between !important;
align-items: baseline !important;
gap: 10px !important;
width: 100% !important;
border: 0 !important;
padding: 3px 0 !important;
font-size: 12px !important;
text-align: right !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
}
.peers-list .block-info-table td::before,
.peers-technical-block .block-info-table td::before,
.gov-overview tbody td::before {
content: attr(data-label);
flex: 0 0 auto !important;
font-weight: 700 !important;
text-transform: uppercase;
font-size: 10px !important;
letter-spacing: .03em;
opacity: .7 !important;
text-align: left !important;
white-space: nowrap !important;
word-break: normal !important;
}
.peers-technical-block .block-info-table td[data-label=""] { justify-content: center !important; }
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
const path = require('path');
let i18n = {};
const languages = ['en', 'es', 'fr', 'eu', 'de', 'it', 'pt', 'zh', 'ar', 'hi', 'ru'];
languages.forEach(language => {
try {
const languagePath = path.join(__dirname, `oasis_${language}.js`);
const languageData = require(languagePath);
i18n[language] = languageData[language];
} catch (error) {
console.error(`Failed to load language file for ${language}:`, error);
}
});
module.exports = i18n;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
module.exports = {
feed: 'createFeedStream',
history: 'createHistoryStream',
hist: 'createHistoryStream',
public: 'getPublicKey',
pub: 'getPublicKey',
log: 'createLogStream',
logt: 'messagesByType',
conf: 'config',
};

124
src/client/gui.js Normal file
View file

@ -0,0 +1,124 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const debug = require('../server/node_modules/debug')('oasis');
const lodash = require('../server/node_modules/lodash');
const ssbClient = require('../server/node_modules/ssb-client');
const ssbConfig = require('../server/node_modules/ssb-config');
const ssbKeys = require('../server/node_modules/ssb-keys');
const { printMetadata } = require('../server/ssb_metadata');
const updateFlagPath = path.join(__dirname, "../server/.update_required");
let internalSSB = null;
try {
const { server } = require('../server/SSB_server');
internalSSB = server;
} catch {}
if (process.env.OASIS_TEST) {
ssbConfig.path = fs.mkdtempSync(path.join(os.tmpdir(), "oasis-"));
ssbConfig.keys = ssbKeys.generate();
}
const socketPath = path.join(ssbConfig.path, "socket");
const publicInteger = ssbConfig.keys.public.replace(".ed25519", "");
const remote = `unix:${socketPath}~noauth:${publicInteger}`;
const connect = (options) =>
new Promise((resolve, reject) => {
ssbClient(process.env.OASIS_TEST ? ssbConfig.keys : null, options)
.then(resolve)
.catch(reject);
});
let closing = false;
let clientHandle;
const attemptConnectionWithBackoff = (attempt = 1) => {
const maxAttempts = 5;
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
return new Promise((resolve, reject) => {
connect({ remote })
.then(resolve)
.catch((error) => {
if (attempt >= maxAttempts) {
return reject(new Error("Failed to connect after multiple attempts"));
}
setTimeout(() => {
attemptConnectionWithBackoff(attempt + 1).then(resolve).catch(reject);
}, delay);
});
});
};
let pendingConnection = null;
const ensureConnection = (customConfig) => {
if (pendingConnection === null) {
pendingConnection = new Promise((resolve) => {
setTimeout(() => {
attemptConnectionWithBackoff()
.then(resolve)
.catch(() => {
resolve(null);
});
});
});
const cancel = () => (pendingConnection = null);
pendingConnection.then(cancel, cancel);
}
return pendingConnection;
};
module.exports = ({ offline, port = 3000, host = 'localhost', isPublic = false }) => {
const customConfig = JSON.parse(JSON.stringify(ssbConfig));
if (offline === true) {
lodash.set(customConfig, "conn.autostart", false);
}
lodash.set(
customConfig,
"conn.hops",
lodash.get(ssbConfig, "conn.hops", lodash.get(ssbConfig.friends, "hops", 0))
);
const cooler = {
open() {
return new Promise((resolve, reject) => {
if (internalSSB) {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
return resolve(internalSSB);
}
if (clientHandle && clientHandle.closed === false) {
return resolve(clientHandle);
}
ensureConnection(customConfig).then((ssb) => {
if (!ssb) return reject(new Error("No SSB server available"));
clientHandle = ssb;
if (closing) {
cooler.close();
reject(new Error("Closing Oasis"));
} else {
const { printMetadata, colors } = require('../server/ssb_metadata');
printMetadata('OASIS GUI', colors.yellow, port, host, offline, isPublic);
resolve(ssb);
}
}).catch(reject);
});
},
close() {
closing = true;
if (clientHandle && clientHandle.closed === false) {
clientHandle.close();
}
},
};
return cooler;
};

210
src/client/middleware.js Normal file
View file

@ -0,0 +1,210 @@
const path = require("path");
const os = require("os");
const Koa = require(path.join(__dirname, "../server/node_modules/koa"));
const koaStatic = require(path.join(__dirname, "../server/node_modules/koa-static"));
const { join } = require("path");
const mount = require(path.join(__dirname, "../server/node_modules/koa-mount"));
function obfuscateClearnetHtml(html) {
if (typeof html !== 'string' || html.length === 0) return html;
const preserve = [];
const stash = (re) => {
html = html.replace(re, (m) => {
preserve.push(m);
return `${preserve.length - 1}`;
});
};
stash(/<pre[\s\S]*?<\/pre>/gi);
stash(/<textarea[\s\S]*?<\/textarea>/gi);
stash(/<style[\s\S]*?<\/style>/gi);
html = html.replace(/<!--[\s\S]*?-->/g, '');
html = html.replace(/>[\s\n\r\t]+</g, '><');
html = html.replace(/[ \t]{2,}/g, ' ');
html = html.replace(/[\r\n]+/g, '');
html = html.replace(/(\d+)/g, (_, i) => preserve[Number(i)] || '');
return html;
}
const collectLocalIPs = () => {
const out = [];
try {
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const info of (ifaces[name] || [])) {
if (info && !info.internal && (info.family === 'IPv4' || info.family === 4)) {
out.push(info.address);
}
}
}
} catch (_) {}
return out;
};
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
const app = new Koa();
const validHosts = [];
const isClearnetPath = (request) => {
const url = String(request.url || '');
return url === '/c' || url.startsWith('/c/') || url.startsWith('/c?');
};
const isValidRequest = (request) => {
if (isClearnetPath(request)) return request.method === 'GET';
if (validHosts.includes(request.hostname) !== true) {
return false;
}
if (request.method !== "GET") {
if (request.header.referer == null) {
return false;
}
try {
const refererUrl = new URL(request.header.referer);
if (validHosts.includes(refererUrl.hostname) !== true) {
return false;
}
if (refererUrl.pathname.startsWith("/blob/")) {
return false;
}
} catch (e) {
return false;
}
}
return true;
};
app.on("error", (err, ctx) => {
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
return;
}
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
console.error(`[400] ${err.message}`);
return null;
}
console.error(err);
if (ctx && isValidRequest(ctx.request)) {
err.message = err.message || 'Internal server error';
err.expose = true;
}
return null;
});
app.use(mount("/assets", assets));
const maptiles = new Koa();
maptiles.use(koaStatic(join(__dirname, "..", "maps", "tiles")));
app.use(mount("/maptiles", maptiles));
const mapcache = new Koa();
mapcache.use(koaStatic(join(__dirname, "..", "maps", "cache")));
app.use(mount("/mapcache", mapcache));
const gamesStatic = new Koa();
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
app.use(mount("/game-assets", gamesStatic));
app.use(mount("/js", koaStatic(path.join(__dirname, 'public/js'))));
app.use(koaStatic(path.join(__dirname, 'public')));
app.use(async (ctx, next) => {
//console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
const isClearnet = isClearnetPath(ctx.request);
const csp = isClearnet
? [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ")
: [
"default-src 'self'",
"script-src 'self' http://localhost:3000/js",
"style-src 'self'",
"img-src 'self'",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"frame-src 'self'",
"form-action 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ");
ctx.set("Content-Security-Policy", csp);
ctx.set("X-Frame-Options", "SAMEORIGIN");
ctx.set("X-Content-Type-Options", "nosniff");
ctx.set("Referrer-Policy", "same-origin");
ctx.set("Permissions-Policy", "speaker=(self)");
const validHostsString = validHosts.join(" or ");
ctx.assert(
isValidRequest(ctx.request),
400,
`Request must be addressed to ${validHostsString} and non-GET requests must contain non-blob referer.`
);
await next();
if (isClearnet && typeof ctx.body === 'string') {
const type = String(ctx.response.type || ctx.response.get('Content-Type') || '').toLowerCase();
if (type.includes('html')) {
ctx.body = obfuscateClearnetHtml(ctx.body);
}
}
});
// pdf viewer
const pdfjsPath = path.join(__dirname, '../server/node_modules/pdfjs-dist/build/pdf.min.js');
app.use(koaStatic(pdfjsPath));
middleware.forEach((m) => app.use(m));
const server = app.listen({ host, port });
server.on("listening", () => {
const address = server.address();
if (typeof address === "string") {
throw new Error("HTTP server should never bind to Unix socket");
}
if (allowHost !== null) {
validHosts.push(allowHost);
}
validHosts.push(address.address);
if (validHosts.includes(host) === false) {
validHosts.push(host);
}
for (const ip of collectLocalIPs()) {
if (validHosts.includes(ip) === false) validHosts.push(ip);
}
for (const loopback of ['localhost', '127.0.0.1']) {
if (validHosts.includes(loopback) === false) validHosts.push(loopback);
}
});
return server;
};

View file

@ -0,0 +1,70 @@
"use strict";
const path = require('path');
const yargs = require(path.join(__dirname, '../server/node_modules/yargs'));
const { hideBin } = require(path.join(__dirname, '../server/node_modules/yargs/helpers'));
const _ = require(path.join(__dirname, '../server/node_modules/lodash'));
const moduleAlias = require(path.join(__dirname, '../server/node_modules/module-alias'));
moduleAlias.addAlias('punycode', 'punycode/');
const HELP_TEXT = `Usage: sh oasis.sh [mode] [options]
Modes:
gui Launch the Oasis web GUI (default if no mode given).
server, pub Launch only the Oasis Sbot (headless, PUB mode).
help, -h, --help Show this help message.
PUB admin commands (require the Oasis Sbot to be running):
whoami Print this Oasis ID.
invite [N] Create an invite code. N = number of uses (default 1).
name <text> Set this Oasis display name.
announce <host> [port] Publish a pub address (default port 8008).
follow <feedId> Follow another Oasis ID / feed.
status Show peer / replication status.
gossip List known gossip peers.
GUI options (forwarded to the backend):
--host=<ip> Hostname / IP the web UI listens on (default: localhost).
Use 0.0.0.0 to expose on a VPS.
--port=<n> Port for the web UI (default: 3000).
--allow-host=<host> Extra hostname allowed when behind a reverse proxy.
--public Public-hosting mode: disables POST and redacts content
from people who haven't opted in to public hosting.
--offline Don't connect to Oasis peers or pubs.
--no-open Don't auto-open a browser tab on launch (useful on VPS).
--debug Verbose logging.
Examples:
sh oasis.sh
sh oasis.sh server
sh oasis.sh invite 100
sh oasis.sh name "My PUB"
sh oasis.sh announce mypub.example.com
sh oasis.sh --host=0.0.0.0 --port=8080 --no-open
`;
const cli = (presets /*, defaultConfigFile */) => {
const argv = process.argv.slice(2);
if (argv.includes('-h') || argv.includes('--help') || argv.includes('-help') || argv.includes('help')) {
process.stdout.write(HELP_TEXT);
process.exit(0);
}
return yargs(hideBin(process.argv))
.scriptName("oasis")
.env("OASIS")
.help(false)
.version(false)
.options("open", { default: _.get(presets, "open", true), type: "boolean" })
.options("offline", { default: _.get(presets, "offline", false), type: "boolean" })
.options("host", { default: _.get(presets, "host", "localhost"), type: "string" })
.options("allow-host", { default: _.get(presets, "allow-host", null), type: "string" })
.options("port", { default: _.get(presets, "port", 3000), type: "number" })
.options("public", { default: _.get(presets, "public", false), type: "boolean" })
.options("debug", { default: _.get(presets, "debug", false), type: "boolean" })
.parserConfiguration({ "strip-aliased": true })
.argv;
};
module.exports = { cli };

View file

@ -0,0 +1,115 @@
ECOin - Copyright (c) - 2014/2025 - GPLv3 - epsylon@riseup.net (https://ecoin.03c8.net)
===========================================
# SOURCES for P2P Crypto-Currency (ECOin) #
===========================================
Testing machine is: Debian GNU/Linux 12 (bookworm) (x86_64).
All of the commands should be executed in a shell.
------------------------------
(0.) Clone the github tree to get the source code:
+ Official:
git clone http://code.03c8.net/epsylon/ecoin
+ Mirror:
git clone https://github.com/epsylon/ecoin
------------------------------
===================================================
# SERVER -ecoind- for P2P Crypto-Currency (ECOin) #
===================================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) Install dependencies:
sudo apt-get install build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Now you should be able to build ecoind:
cd src/
make -f makefile.linux USE_UPNP=- USE_IPV6=-
strip ecoind
An executable named 'ecoind' will be built.
Now you can launch: ./ecoind to run your ECOin server.
------------------------------
============================================
# WALLET for P2P Crypto-Currency (ECOin) #
============================================
(0.) Version libraries:
- Libboost -> source code 1.68 provided at: src/boost_1_68_0
(1.) First, make sure that the required packages for Qt5 development (an the others required for building the daemon) are installed:
sudo apt-get install qt5-qmake qtbase5-dev build-essential libssl-dev libssl3 libdb5.3-dev libdb5.3++-dev libleveldb-dev miniupnpc libminiupnpc-dev
+ Optionally install qrencode (and set USE_QRCODE=1):
sudo apt-get install libqrencode-dev
(2.) Then execute the following:
qmake USE_UPNP=- USE_IPV6=-
make
An executable named 'ecoin-qt' will be built.
Now you can launch: ./ecoin-qt to run your ECOin wallet/GUI.
------------------------------
========================================
+ CPU MINER for ECOin (Unix/GNU-Linux) #
========================================
See doc/MINING.txt for detailed instructions on running /ecoin-miner/ on different platforms.
+ GNU/Linux:
cd miner/
sh build.sh
An executable named 'cpuminer' will be built.
Now you can launch: ./cpuminer to run your ECOin PoW miner.
======================================
For a list of command-line options:
./ecoind --help
To start the ECOin daemon:
./ecoind -daemon
For ECOin-QT Wallet
./ecoin-qt
For debugging:
tail -f /home/$USER/.ecoin/debug.log
======================================

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1,3 @@
{
"discardedItems": []
}

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1 @@
[]

View file

@ -0,0 +1,109 @@
const fs = require('fs');
const path = require('path');
const configFilePath = path.join(__dirname, 'oasis-config.json');
if (!fs.existsSync(configFilePath)) {
const defaultConfig = {
"themes": {
"current": "Dark-SNH"
},
"ux": {
"current": "blocks"
},
"modules": {
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"fediverseMod": "off",
"invitesMod": "on",
"walletMod": "on",
"legacyMod": "on",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
"docsMod": "on",
"audiosMod": "on",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "on",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "on",
"melodyMod": "on",
"agendaMod": "on",
"aiMod": "on",
"aiNavMod": "on",
"forumMod": "on",
"gamesMod": "on",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
"logsMod": "on",
"mapsMod": "on",
"chatsMod": "on",
"torrentsMod": "on",
"graphosMod": "on",
"larpMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
"user": "",
"pass": "",
"fee": "5"
},
"walletPub": {
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
"ssbLogStream": {
"limit": 2000
},
"homePage": "activity",
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
"lanBroadcasting": true
};
fs.writeFileSync(configFilePath, JSON.stringify(defaultConfig, null, 2));
}
const getConfig = () => {
const configData = fs.readFileSync(configFilePath);
const cfg = JSON.parse(configData);
if (!['whole', 'mutuals', 'only-lan'].includes(cfg.wish)) cfg.wish = 'whole';
if (cfg.pmVisibility !== 'whole' && cfg.pmVisibility !== 'mutuals') cfg.pmVisibility = 'whole';
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks';
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
return cfg;
};
const saveConfig = (newConfig) => {
fs.writeFileSync(configFilePath, JSON.stringify(newConfig, null, 2));
};
module.exports = {
getConfig,
saveConfig,
};

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1 @@
{"pending":[],"accepted":[],"lastAcceptMs":0}

View file

@ -0,0 +1,13 @@
{
"audios": [],
"bookmarks": [],
"calendars": [],
"chats": [],
"documents": [],
"images": [],
"maps": [],
"pads": [],
"shops": [],
"torrents": [],
"videos": []
}

View file

@ -0,0 +1,79 @@
{
"themes": {
"current": "OasisMobile"
},
"ux": {
"current": "blocks"
},
"modules": {
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"fediverseMod": "on",
"invitesMod": "on",
"walletMod": "off",
"legacyMod": "off",
"cipherMod": "on",
"bookmarksMod": "on",
"videosMod": "on",
"docsMod": "off",
"audiosMod": "on",
"tagsMod": "on",
"imagesMod": "on",
"trendingMod": "on",
"eventsMod": "on",
"tasksMod": "on",
"marketMod": "on",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "on",
"opinionsMod": "on",
"padsMod": "on",
"calendarsMod": "on",
"transfersMod": "off",
"feedMod": "on",
"pixeliaMod": "on",
"melodyMod": "on",
"agendaMod": "on",
"aiMod": "off",
"aiNavMod": "off",
"forumMod": "on",
"gamesMod": "off",
"jobsMod": "on",
"shopsMod": "on",
"projectsMod": "on",
"bankingMod": "off",
"parliamentMod": "on",
"courtsMod": "off",
"favoritesMod": "on",
"logsMod": "off",
"mapsMod": "off",
"chatsMod": "on",
"torrentsMod": "off",
"graphosMod": "on",
"larpMod": "on"
},
"wallet": {
"url": "http://localhost:7474",
"user": "",
"pass": "",
"fee": "5"
},
"walletPub": {
"pubId": ""
},
"ai": {
"prompt": "Provide an informative and precise response."
},
"ssbLogStream": {
"limit": 2000
},
"homePage": "activity",
"language": "en",
"wish": "whole",
"pmVisibility": "whole",
"lanBroadcasting": true
}

View file

@ -0,0 +1,61 @@
{
"logging": {
"level": "notice"
},
"caps": {
"shs": "H5EC+V5BU9s0lWxCkt4z8a095Sj8a6TgiLKPYi1JD7s="
},
"pub": false,
"local": true,
"friends": {
"dunbar": 300,
"hops": 2
},
"gossip": {
"connections": 20,
"local": true,
"friends": true,
"seed": true,
"global": true
},
"replicationScheduler": {
"autostart": true,
"partialReplication": null
},
"autofollow": {
"enabled": false,
"feeds": []
},
"connections": {
"seeds": [],
"incoming": {
"net": [
{
"scope": ["device", "local"],
"transform": "shs",
"port": 8008
}
],
"unix": [
{
"scope": [
"device",
"local",
"private"
],
"transform": "noauth"
}
]
},
"outgoing": {
"net": [
{
"transform": "shs"
}
],
"tunnel": [],
"onion": [],
"ws": []
}
}
}

View file

@ -0,0 +1,35 @@
let _inboxCount = 0;
let _carbonHcT = 0;
let _carbonHcH = 0;
let _lastRefresh = 0;
let _onlinePeers = null;
let _inboxUnread = null;
let _lastSyncTs = null;
let _ecoValue = null;
let _lastActivity = null;
let _maxBlockBytes = 0;
let _inhabitantCount = 0;
module.exports = {
getInboxCount: () => _inboxCount,
setInboxCount: (n) => { _inboxCount = n; },
getCarbonHcT: () => _carbonHcT,
setCarbonHcT: (n) => { _carbonHcT = n; },
getCarbonHcH: () => _carbonHcH,
setCarbonHcH: (n) => { _carbonHcH = n; },
getLastRefresh: () => _lastRefresh,
setLastRefresh: (t) => { _lastRefresh = t; },
getOnlinePeerCount: () => _onlinePeers,
setOnlinePeerCount: (n) => { _onlinePeers = n; },
getInboxUnreadCount: () => _inboxUnread,
setInboxUnreadCount: (n) => { _inboxUnread = n; },
getLastSyncTs: () => _lastSyncTs,
setLastSyncTs: (t) => { _lastSyncTs = t; },
getEcoValue: () => _ecoValue,
setEcoValue: (v) => { _ecoValue = v; },
getLastActivity: () => _lastActivity,
setLastActivity: (a) => { _lastActivity = a; },
getMaxBlockBytes: () => _maxBlockBytes,
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
getInhabitantCount: () => _inhabitantCount,
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); }
};

View file

@ -0,0 +1,7 @@
{
"name": "SNH \"La Plaza\"",
"description": "A shared place to begin a utopia ...",
"url": "https://pub.solarnethub.com",
"code": "pub.solarnethub.com:8008:@0qSCyK3xyL71X4qKkmf84Cb2riP6OeUqxCvbP2Z6HWs=.ed25519~7mC1c1gBJe8pmnWI9eIUYpFtEPUOiuHAxbzfxP/f0Ak=",
"createdAt": "2026-05-24T00:00:00.000Z"
}

View file

@ -0,0 +1 @@
{}

285
src/games/8ball/index.html Normal file
View file

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>8Ball Pool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: crosshair; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 24px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 6px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">8BALL POOL</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>SHOTS: <b id="shots">0</b></span>
<span>BEST: <b id="best">-</b></span>
</div>
<canvas id="c" width="600" height="340"></canvas>
<div id="msg">Click to aim &amp; shoot. Hold = more power.</div>
<div id="controls">Click on table to aim cue ball &nbsp;|&nbsp; Hold longer = more power &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="8ball">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TABLE_X = 20, TABLE_Y = 20, TABLE_W = 560, TABLE_H = 280;
const POCKET_R = 20, BALL_R = 10;
const FRICTION = 0.975;
const POCKETS = [
[TABLE_X, TABLE_Y], [TABLE_X + TABLE_W/2, TABLE_Y - 6], [TABLE_X + TABLE_W, TABLE_Y],
[TABLE_X, TABLE_Y + TABLE_H], [TABLE_X + TABLE_W/2, TABLE_Y + TABLE_H + 6], [TABLE_X + TABLE_W, TABLE_Y + TABLE_H]
];
const BALL_COLORS = ['#fff','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000','#000','#ffd700','#1e90ff','#dc143c','#800080','#ff8c00','#006400','#8b0000'];
const BALL_STRIPE = [false,false,false,false,false,false,false,false,false,true,true,true,true,true,true,true];
let score = 0, shots = 0, best = parseInt(localStorage.getItem('8ball_best') || '-1');
let balls = [], state = 'idle', mouseDown = false, mousePos = {x:0,y:0}, powerStart = 0, power = 0;
let foul = false, gameOver = false;
function initBalls() {
balls = [];
const cx = TABLE_X + TABLE_W * 0.72, cy = TABLE_Y + TABLE_H / 2;
const sp = BALL_R * 2.05;
const rack = [
[0,0],[1,-0.5],[1,0.5],[2,-1],[2,0],[2,1],[3,-1.5],[3,-0.5],[3,0.5],[3,1.5],
[4,-2],[4,-1],[4,0],[4,1],[4,2]
];
for (let i = 0; i < 15; i++) {
const [row, col] = rack[i];
balls.push({ x: cx + row * sp * 0.866, y: cy + col * sp, vx: 0, vy: 0, potted: false, idx: i + 1 });
}
balls.push({ x: TABLE_X + TABLE_W * 0.25, y: cy, vx: 0, vy: 0, potted: false, idx: 0 });
}
function resetGame() {
score = 0; shots = 0; foul = false; gameOver = false;
state = 'aiming';
document.getElementById('score').textContent = '0';
document.getElementById('shots').textContent = '0';
document.getElementById('msg').textContent = 'Click to aim & shoot. Hold = more power.';
document.getElementById('scoreSubmit').style.display = 'none';
initBalls();
}
const cue = () => balls.find(b => b.idx === 0);
function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); }
function processPhysics() {
let moving = true;
let steps = 0;
while (moving && steps < 800) {
steps++;
moving = false;
for (const b of balls) {
if (b.potted) continue;
b.x += b.vx; b.y += b.vy;
b.vx *= FRICTION; b.vy *= FRICTION;
if (Math.abs(b.vx) < 0.05) b.vx = 0;
if (Math.abs(b.vy) < 0.05) b.vy = 0;
if (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05) moving = true;
const lx = TABLE_X + BALL_R, rx = TABLE_X + TABLE_W - BALL_R;
const ty = TABLE_Y + BALL_R, by = TABLE_Y + TABLE_H - BALL_R;
if (b.x < lx) { b.x = lx; b.vx = Math.abs(b.vx) * 0.8; }
if (b.x > rx) { b.x = rx; b.vx = -Math.abs(b.vx) * 0.8; }
if (b.y < ty) { b.y = ty; b.vy = Math.abs(b.vy) * 0.8; }
if (b.y > by) { b.y = by; b.vy = -Math.abs(b.vy) * 0.8; }
for (const p of POCKETS) {
if (Math.hypot(b.x - p[0], b.y - p[1]) < POCKET_R) {
b.potted = true; b.vx = 0; b.vy = 0;
if (b.idx !== 0) {
if (b.idx === 8) { foul = true; }
else { score++; document.getElementById('score').textContent = score; }
} else {
b.x = TABLE_X + TABLE_W * 0.25; b.y = TABLE_Y + TABLE_H / 2; b.potted = false;
}
break;
}
}
}
const active = balls.filter(b => !b.potted);
for (let i = 0; i < active.length; i++) {
for (let j = i + 1; j < active.length; j++) {
const a = active[i], b2 = active[j];
const dx = b2.x - a.x, dy = b2.y - a.y;
const d = Math.hypot(dx, dy);
if (d < BALL_R * 2 && d > 0.001) {
const nx = dx / d, ny = dy / d;
const rel = (a.vx - b2.vx) * nx + (a.vy - b2.vy) * ny;
if (rel > 0) {
a.vx -= rel * nx; a.vy -= rel * ny;
b2.vx += rel * nx; b2.vy += rel * ny;
}
const overlap = BALL_R * 2 - d;
a.x -= overlap / 2 * nx; a.y -= overlap / 2 * ny;
b2.x += overlap / 2 * nx; b2.y += overlap / 2 * ny;
}
}
}
}
}
function shoot(targetX, targetY, pw) {
const c = cue();
if (!c) return;
shots++;
document.getElementById('shots').textContent = shots;
const angle = Math.atan2(targetY - c.y, targetX - c.x);
const spd = pw * 0.28;
c.vx = Math.cos(angle) * spd;
c.vy = Math.sin(angle) * spd;
processPhysics();
const remaining = balls.filter(b => !b.potted && b.idx !== 0 && b.idx !== 8);
if (remaining.length === 0) {
const eight = balls.find(b => b.idx === 8);
if (!eight || eight.potted) {
endGame();
}
} else if (foul) {
endGame();
}
}
function endGame() {
gameOver = true;
state = 'over';
const finalScore = foul ? Math.floor(score / 2) : score;
document.getElementById('score').textContent = finalScore;
if (best < 0 || finalScore > best) {
best = finalScore;
localStorage.setItem('8ball_best', best);
document.getElementById('best').textContent = best;
}
document.getElementById('msg').textContent = foul ? `FOUL! 8-ball potted early. Score: ${finalScore}. SPACE = new game` : `Game over! Potted ${finalScore} balls in ${shots} shots. SPACE = new game`;
document.getElementById('scoreInput').value = finalScore;
document.getElementById('scoreSubmit').style.display = 'block';
}
function getCanvasPos(e) {
const r = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: (touch.clientX - r.left) * canvas.width / r.width, y: (touch.clientY - r.top) * canvas.height / r.height };
}
canvas.addEventListener('mousedown', e => {
if (state !== 'aiming') return;
mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); power = 0;
});
canvas.addEventListener('mousemove', e => { mousePos = getCanvasPos(e); });
canvas.addEventListener('mouseup', e => {
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
});
canvas.addEventListener('touchstart', e => { e.preventDefault(); if (state !== 'aiming') return; mouseDown = true; mousePos = getCanvasPos(e); powerStart = Date.now(); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); mousePos = getCanvasPos(e); }, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
if (!mouseDown || state !== 'aiming') return;
mouseDown = false;
power = Math.min((Date.now() - powerStart) / 1000 * 80, 100);
shoot(mousePos.x, mousePos.y, power);
}, { passive: false });
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); resetGame(); }
});
function drawTable() {
ctx.fillStyle = '#2d5a1b';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a6b30';
ctx.fillRect(TABLE_X, TABLE_Y, TABLE_W, TABLE_H);
ctx.fillStyle = '#8B4513';
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y + TABLE_H, TABLE_W + 28, 14);
ctx.fillRect(TABLE_X - 14, TABLE_Y - 14, 14, TABLE_H + 28);
ctx.fillRect(TABLE_X + TABLE_W, TABLE_Y - 14, 14, TABLE_H + 28);
for (const p of POCKETS) {
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(p[0], p[1], POCKET_R, 0, Math.PI * 2); ctx.fill();
}
}
function drawBall(b) {
if (b.potted) return;
const stripe = BALL_STRIPE[b.idx];
ctx.fillStyle = BALL_COLORS[b.idx];
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.fill();
if (stripe) {
ctx.save();
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = '#fff';
ctx.fillRect(b.x - BALL_R, b.y - BALL_R * 0.4, BALL_R * 2, BALL_R * 0.8);
ctx.restore();
}
if (b.idx > 0) {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R * 0.38, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.font = `bold ${BALL_R * 0.6}px monospace`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(b.idx, b.x, b.y + 0.5);
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(b.x, b.y, BALL_R, 0, Math.PI * 2); ctx.stroke();
}
function drawAim() {
const c = cue();
if (!c) return;
const angle = Math.atan2(mousePos.y - c.y, mousePos.x - c.x);
const pw = mouseDown ? Math.min((Date.now() - powerStart) / 1000 * 80, 100) : 30;
ctx.strokeStyle = mouseDown ? `rgba(255,${Math.round(165*(1-pw/100))},0,0.7)` : 'rgba(255,255,255,0.4)';
ctx.lineWidth = mouseDown ? 2 : 1;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(c.x, c.y);
ctx.lineTo(c.x + Math.cos(angle) * 120, c.y + Math.sin(angle) * 120);
ctx.stroke();
ctx.setLineDash([]);
if (mouseDown) {
ctx.fillStyle = '#FFA500';
ctx.fillRect(TABLE_X, TABLE_Y + TABLE_H + 6, pw / 100 * TABLE_W, 6);
ctx.strokeStyle = '#555'; ctx.lineWidth = 1;
ctx.strokeRect(TABLE_X, TABLE_Y + TABLE_H + 6, TABLE_W, 6);
}
}
if (best >= 0) document.getElementById('best').textContent = best;
resetGame();
function loop() {
ctx.clearRect(0, 0, W, H);
drawTable();
balls.forEach(drawBall);
if (state === 'aiming') drawAim();
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#0a5e2a"/>
<rect x="6" y="6" width="108" height="68" fill="#0a6e2e" rx="3"/>
<rect x="8" y="8" width="104" height="64" fill="#0d8040" rx="2"/>
<circle cx="20" cy="20" r="5" fill="#fff"/>
<circle cx="40" cy="15" r="5" fill="#ff0"/>
<circle cx="40" cy="25" r="5" fill="#00f"/>
<circle cx="55" cy="20" r="5" fill="#f00"/>
<circle cx="55" cy="10" r="5" fill="#800080"/>
<circle cx="55" cy="30" r="5" fill="#f90"/>
<circle cx="70" cy="15" r="5" fill="#0a0"/>
<circle cx="70" cy="25" r="5" fill="#700"/>
<circle cx="85" cy="20" r="5" fill="#000"/>
<text x="85" y="24" font-size="6" fill="#fff" text-anchor="middle" font-weight="bold">8</text>
<circle cx="6" cy="6" r="4" fill="#000"/>
<circle cx="114" cy="6" r="4" fill="#000"/>
<circle cx="6" cy="74" r="4" fill="#000"/>
<circle cx="114" cy="74" r="4" fill="#000"/>
<circle cx="60" cy="6" r="4" fill="#000"/>
<circle cx="60" cy="74" r="4" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arkanoid</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; cursor: none; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARKANOID</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="560" height="420"></canvas>
<div id="msg">Press SPACE or CLICK to start</div>
<div id="controls">&#8592;&#8594; or MOUSE — Move paddle &nbsp;|&nbsp; SPACE / CLICK — Launch ball</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="arkanoid">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 80, PADDLE_H = 12, PADDLE_Y = H - 30;
const BALL_R = 8;
const BRICK_COLS = 10, BRICK_ROWS = 5;
const BRICK_W = 48, BRICK_H = 16, BRICK_GAP = 4;
const BRICK_OFF_X = (W - (BRICK_COLS * (BRICK_W + BRICK_GAP) - BRICK_GAP)) / 2;
const BRICK_OFF_Y = 40;
const BRICK_COLORS = ['#e74c3c', '#e67e22', '#FFA500', '#2ecc71', '#3498db'];
let state = 'idle';
let score = 0, lives = 3, level = 1;
let paddle, ball, bricks, attached;
function initGame() {
paddle = { x: W / 2 - PADDLE_W / 2, y: PADDLE_Y, w: PADDLE_W, h: PADDLE_H };
resetBall();
initBricks();
}
function resetBall() {
ball = { x: paddle.x + paddle.w / 2, y: PADDLE_Y - BALL_R - 1, vx: 3 + level * 0.3, vy: -(4 + level * 0.3) };
attached = true;
}
function initBricks() {
bricks = [];
for (let r = 0; r < BRICK_ROWS; r++) {
for (let c = 0; c < BRICK_COLS; c++) {
bricks.push({
x: BRICK_OFF_X + c * (BRICK_W + BRICK_GAP),
y: BRICK_OFF_Y + r * (BRICK_H + BRICK_GAP),
w: BRICK_W, h: BRICK_H,
alive: true,
color: BRICK_COLORS[r % BRICK_COLORS.length],
points: (BRICK_ROWS - r) * 10
});
}
}
}
function launch() {
if (state === 'idle' || state === 'over' || state === 'win') {
if (state === 'over') { score = 0; lives = 3; level = 1; document.getElementById('score').textContent = '0'; document.getElementById('lives').textContent = '3'; document.getElementById('level').textContent = '1'; }
if (state === 'win') { level++; document.getElementById('level').textContent = level; }
initGame();
state = 'play';
attached = false;
document.getElementById('msg').textContent = '';
return;
}
if (attached) {
attached = false;
}
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') { e.preventDefault(); launch(); }
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
canvas.addEventListener('click', launch);
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * canvas.width / rect.width;
paddle.x = Math.max(0, Math.min(W - paddle.w, mx - paddle.w / 2));
if (attached) ball.x = paddle.x + paddle.w / 2;
});
function reflect(ball, bx, by, bw, bh) {
const overlapLeft = ball.x + BALL_R - bx;
const overlapRight = bx + bw - (ball.x - BALL_R);
const overlapTop = ball.y + BALL_R - by;
const overlapBottom = by + bh - (ball.y - BALL_R);
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop || minOverlap === overlapBottom) ball.vy *= -1;
else ball.vx *= -1;
}
function loop() {
ctx.clearRect(0, 0, W, H);
if (state === 'play') {
if (keys['ArrowLeft']) paddle.x = Math.max(0, paddle.x - 6);
if (keys['ArrowRight']) paddle.x = Math.min(W - paddle.w, paddle.x + 6);
if (attached) {
ball.x = paddle.x + paddle.w / 2;
} else {
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - BALL_R < 0) { ball.x = BALL_R; ball.vx = Math.abs(ball.vx); }
if (ball.x + BALL_R > W) { ball.x = W - BALL_R; ball.vx = -Math.abs(ball.vx); }
if (ball.y - BALL_R < 0) { ball.y = BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.y + BALL_R >= paddle.y && ball.y + BALL_R <= paddle.y + paddle.h + Math.abs(ball.vy) &&
ball.x >= paddle.x - BALL_R && ball.x <= paddle.x + paddle.w + BALL_R) {
const rel = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = rel * speed * 1.1;
ball.vy = -Math.abs(ball.vy);
ball.y = paddle.y - BALL_R - 1;
}
if (ball.y - BALL_R > H) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = 'GAME OVER — Press SPACE'; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { resetBall(); attached = true; }
}
for (const b of bricks) {
if (!b.alive) continue;
if (ball.x + BALL_R > b.x && ball.x - BALL_R < b.x + b.w && ball.y + BALL_R > b.y && ball.y - BALL_R < b.y + b.h) {
b.alive = false;
score += b.points;
document.getElementById('score').textContent = score;
reflect(ball, b.x, b.y, b.w, b.h);
break;
}
}
if (bricks.every(b => !b.alive)) {
state = 'win';
document.getElementById('msg').textContent = 'LEVEL CLEAR! — Press SPACE for next level';
}
}
}
bricks.forEach(b => {
if (!b.alive) return;
ctx.fillStyle = b.color;
ctx.beginPath();
ctx.roundRect(b.x, b.y, b.w, b.h, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.roundRect(paddle.x, paddle.y, paddle.w, paddle.h, 4);
ctx.fill();
ctx.fillStyle = '#FFD700';
ctx.fillRect(paddle.x + 8, paddle.y + 3, paddle.w - 16, 3);
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.arc(ball.x - 3, ball.y - 3, BALL_R * 0.4, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<g>
<rect x="20" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="82" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="144" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="206" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="268" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
<rect x="330" y="20" width="54" height="18" rx="3" fill="#e74c3c"/>
</g>
<g>
<rect x="20" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="82" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="144" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="206" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="268" y="46" width="54" height="18" rx="3" fill="#3498db"/>
<rect x="330" y="46" width="54" height="18" rx="3" fill="#3498db"/>
</g>
<g>
<rect x="20" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="82" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="144" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="206" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="268" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
<rect x="330" y="72" width="54" height="18" rx="3" fill="#2ecc71"/>
</g>
<g>
<rect x="20" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="82" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="144" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
<rect x="330" y="98" width="54" height="18" rx="3" fill="#FFA500"/>
</g>
<circle cx="220" cy="155" r="9" fill="#fff"/>
<rect x="155" y="190" width="90" height="14" rx="5" fill="#FFA500"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artillery</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#controls-panel { display: flex; gap: 16px; align-items: center; padding: 8px; flex-wrap: wrap; justify-content: center; }
#controls-panel label { color: #aaa; font-size: 13px; }
#controls-panel input[type=range] { width: 120px; }
#controls-panel span { color: #FFA500; font-size: 14px; min-width: 40px; }
#fire-btn { background: #3a0000; border: 1px solid #f44; color: #f44; padding: 6px 20px; cursor: pointer; font-family: monospace; font-size: 15px; font-weight: bold; }
#fire-btn:hover { background: #5a0000; }
#msg { font-size: 15px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ARTILLERY</span>
</div>
<div id="ui">
<span>ROUND: <b id="roundEl">1/5</b></span>
<span>SHOTS: <b id="shotsEl">0</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>WIND: <b id="windEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c" width="800" height="400"></canvas>
<div id="controls-panel">
<label>Angle: <input type="range" id="angleInput" min="1" max="89" value="45"> <span id="angleVal">45</span>°</label>
<label>Power: <input type="range" id="powerInput" min="1" max="100" value="60"> <span id="powerVal">60</span></label>
<button id="fire-btn">FIRE!</button>
</div>
<div id="msg">Adjust angle and power, then fire!</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="artillery">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const TOTAL_ROUNDS = 5;
let round = 1, shots = 0, score = 0, roundShots = 0;
let terrain = [], cannonY = 0, targetX = 0, targetY = 0, wind = 0;
let lastPath = [], lastHit = null, hitFlag = false;
let gameOver = false;
let best = parseInt(localStorage.getItem('artillery_best') || '-1');
function seededRand(seed) {
let s = seed;
return function() { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateTerrain(seed) {
const rng = seededRand(seed);
const pts = new Array(80).fill(0);
pts[0] = 150 + rng() * 80;
pts[79] = 150 + rng() * 80;
function subdivide(arr, lo, hi, rough) {
if (hi - lo <= 1) return;
const mid = Math.floor((lo + hi) / 2);
arr[mid] = (arr[lo] + arr[hi]) / 2 + (rng() - 0.5) * rough;
subdivide(arr, lo, mid, rough * 0.6);
subdivide(arr, mid, hi, rough * 0.6);
}
subdivide(pts, 0, 79, 100);
for (let i = 0; i < 80; i++) pts[i] = Math.max(60, Math.min(H - 30, pts[i]));
return pts;
}
function terrainHeightAt(x) {
const idx = Math.floor(x / (W / 79));
const clamped = Math.max(0, Math.min(78, idx));
return terrain[clamped];
}
function startRound() {
const seed = Date.now() + round * 1337;
terrain = generateTerrain(seed);
const rng = seededRand(seed + 42);
targetX = Math.floor(W * 0.6 + rng() * W * 0.3);
targetY = terrainHeightAt(targetX) - 1;
wind = (rng() - 0.5) * 16;
roundShots = 0;
lastPath = []; lastHit = null; hitFlag = false;
document.getElementById('windEl').textContent = wind.toFixed(1);
document.getElementById('roundEl').textContent = `${round}/${TOTAL_ROUNDS}`;
document.getElementById('msg').textContent = `Round ${round} — Adjust angle and power, then fire!`;
}
function calcTrajectory(angleDeg, pw) {
const angle = angleDeg * Math.PI / 180;
let vx = Math.cos(angle) * pw * 0.45;
let vy = -Math.sin(angle) * pw * 0.45;
const gravity = 0.18;
const windA = wind * 0.012;
const cannonX = 30;
const startY = terrainHeightAt(cannonX) - 6;
let x = cannonX, y = startY;
const path = [[x, y]];
let hitX = null;
for (let i = 0; i < 500; i++) {
vx += windA; vy += gravity;
x += vx; y += vy;
if (x < 0 || x > W) break;
if (y > H) break;
const th = terrainHeightAt(x);
if (y >= th) { hitX = x; break; }
path.push([x, y]);
}
return { path, hitX };
}
function fire() {
if (gameOver) return;
lastPath = [];
lastHit = null;
hitFlag = false;
const angle = parseInt(document.getElementById('angleInput').value);
const pw = parseInt(document.getElementById('powerInput').value);
const { path, hitX } = calcTrajectory(angle, pw);
lastPath = path;
lastHit = hitX;
shots++; roundShots++;
document.getElementById('shotsEl').textContent = shots;
if (hitX !== null && Math.abs(hitX - targetX) < 28) {
hitFlag = true;
const roundScore = Math.max(10, 100 - (roundShots - 1) * 15) + (roundShots === 1 ? 50 : 0);
score += roundScore;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `HIT! +${roundScore} pts. ${round < TOTAL_ROUNDS ? 'Next round...' : 'Game over!'}`;
setTimeout(() => {
lastPath = []; lastHit = null; hitFlag = false;
if (round < TOTAL_ROUNDS) { round++; startRound(); } else { endGame(); }
}, 1400);
} else {
const dist = hitX ? Math.abs(Math.round(hitX - targetX)) : '?';
document.getElementById('msg').textContent = `Miss! Distance: ${dist}px from target.`;
}
}
function endGame() {
gameOver = true;
if (best < 0 || score > best) {
best = score;
localStorage.setItem('artillery_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('msg').textContent = `All rounds done! Final score: ${score}. SPACE = new game`;
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
round = 1; shots = 0; score = 0; roundShots = 0; gameOver = false;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('shotsEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startRound();
}
document.getElementById('angleInput').addEventListener('input', function() { document.getElementById('angleVal').textContent = this.value; });
document.getElementById('powerInput').addEventListener('input', function() { document.getElementById('powerVal').textContent = this.value; });
document.getElementById('fire-btn').addEventListener('click', fire);
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); if (gameOver) newGame(); else fire(); } });
if (best >= 0) document.getElementById('bestEl').textContent = best;
function drawTerrain() {
ctx.fillStyle = '#4a7c3f';
ctx.beginPath();
ctx.moveTo(0, H);
for (let i = 0; i < 80; i++) {
const x = i * (W / 79);
ctx.lineTo(x, terrain[i]);
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#2d5a25'; ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < 80; i++) ctx.lineTo(i * (W / 79), terrain[i]);
ctx.stroke();
}
function drawSky() {
const grad = ctx.createLinearGradient(0, 0, 0, H * 0.7);
grad.addColorStop(0, '#000020');
grad.addColorStop(1, '#1a3a6a');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
function drawCannon() {
const cx = 30, cy = terrainHeightAt(30);
const angle = parseInt(document.getElementById('angleInput').value) * Math.PI / 180;
ctx.fillStyle = '#888';
ctx.fillRect(cx - 10, cy - 8, 20, 10);
ctx.strokeStyle = '#aaa'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx, cy - 3);
ctx.lineTo(cx + Math.cos(angle) * 28, cy - 3 - Math.sin(angle) * 28);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.beginPath(); ctx.arc(cx, cy + 2, 8, 0, Math.PI * 2); ctx.fill();
}
function drawTarget() {
const x = targetX, y = targetY;
ctx.strokeStyle = '#f44'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x - 14, y); ctx.lineTo(x + 14, y); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x, y + 14); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#f44'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 22, y - 16); ctx.lineTo(x + 22, y - 5); ctx.stroke();
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.moveTo(x + 22, y - 16); ctx.lineTo(x + 34, y - 10); ctx.lineTo(x + 22, y - 5); ctx.fill();
}
function drawTrajectory() {
if (lastPath.length < 2) return;
ctx.strokeStyle = hitFlag ? 'rgba(100,255,100,0.6)' : 'rgba(255,200,0,0.5)';
ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]);
ctx.beginPath();
lastPath.forEach((pt, i) => i === 0 ? ctx.moveTo(pt[0], pt[1]) : ctx.lineTo(pt[0], pt[1]));
ctx.stroke(); ctx.setLineDash([]);
if (lastHit !== null) {
const hy = terrainHeightAt(lastHit);
ctx.fillStyle = hitFlag ? '#0f0' : '#f80';
ctx.beginPath(); ctx.arc(lastHit, hy, 8, 0, Math.PI * 2); ctx.fill();
}
}
function drawWind() {
const arrow = wind >= 0 ? '→' : '←';
const strength = Math.abs(wind).toFixed(1);
ctx.fillStyle = '#87CEEB'; ctx.font = '13px monospace';
ctx.fillText(`WIND ${arrow} ${strength}`, W - 130, 22);
}
newGame();
function loop() {
drawSky();
if (terrain.length) {
drawTerrain();
drawTrajectory();
drawCannon();
drawTarget();
drawWind();
}
requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#87CEEB"/>
<polygon points="0,80 0,55 20,50 35,45 50,48 65,40 80,50 95,42 110,52 120,48 120,80" fill="#4a7c3f"/>
<rect x="8" y="56" width="20" height="8" fill="#555" rx="2"/>
<rect x="20" y="50" width="14" height="5" fill="#666" rx="2" transform="rotate(-30 27 52)"/>
<circle cx="30" cy="45" r="3" fill="#f44" opacity="0.8"/>
<circle cx="50" cy="35" r="2.5" fill="#f44" opacity="0.6"/>
<circle cx="70" cy="28" r="2" fill="#f44" opacity="0.4"/>
<rect x="95" y="38" width="8" height="12" fill="#f00" rx="1"/>
<polygon points="99,38 95,32 103,32" fill="#f00"/>
<text x="60" y="20" font-size="10" fill="#fff" text-anchor="middle" font-weight="bold" opacity="0.8">~</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Asteroids</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ASTEROIDS</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>LIVES: <b id="lives">3</b></span>
<span>LEVEL: <b id="level">1</b></span>
</div>
<canvas id="c" width="600" height="420"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8592;&#8594; Rotate &nbsp;|&nbsp; &#8593; Thrust &nbsp;|&nbsp; SPACE Shoot</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="asteroids">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let state = 'idle';
let score = 0, lives = 3, level = 1;
let ship, bullets, asteroids, invincible, frames;
const SHIP_SIZE = 14;
const BULLET_SPEED = 9;
const TURN_SPEED = 0.065;
const THRUST = 0.18;
const FRICTION = 0.985;
const stars = Array.from({ length: 80 }, () => ({
x: Math.random() * W, y: Math.random() * H, r: Math.random() * 1.5 + 0.5, b: Math.random() * 0.7 + 0.3
}));
function initGame() {
ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, cooldown: 0 };
bullets = [];
invincible = 120;
frames = 0;
spawnAsteroids();
}
function randAsteroid(size, x, y) {
const angle = Math.random() * Math.PI * 2;
const speed = (0.6 + Math.random() * 0.8) * (level * 0.15 + 1);
const pts = 7 + Math.floor(Math.random() * 5);
const verts = Array.from({ length: pts }, (_, i) => {
const a = (i / pts) * Math.PI * 2;
const r = size * (0.75 + Math.random() * 0.5);
return { x: Math.cos(a) * r, y: Math.sin(a) * r };
});
return {
x: x ?? (Math.random() < 0.5 ? Math.random() * 100 : W - Math.random() * 100),
y: y ?? (Math.random() < 0.5 ? Math.random() * 100 : H - Math.random() * 100),
vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
size, verts, rot: 0, rotSpeed: (Math.random() - 0.5) * 0.04
};
}
function spawnAsteroids() {
asteroids = [];
for (let i = 0; i < 3 + level; i++) asteroids.push(randAsteroid(38));
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
score = 0; lives = 3; level = 1;
document.getElementById('score').textContent = '0';
document.getElementById('lives').textContent = '3';
document.getElementById('level').textContent = '1';
initGame(); state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function wrap(obj) {
if (obj.x < -50) obj.x = W + 50;
if (obj.x > W + 50) obj.x = -50;
if (obj.y < -50) obj.y = H + 50;
if (obj.y > H + 50) obj.y = -50;
}
function drawShip(s, alpha) {
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
ctx.strokeStyle = `rgba(255,165,0,${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(SHIP_SIZE, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.6);
ctx.lineTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.6);
ctx.closePath();
ctx.stroke();
if (keys['ArrowUp'] && state === 'play' && frames % 4 < 2) {
ctx.fillStyle = `rgba(255,100,0,${alpha})`;
ctx.beginPath();
ctx.moveTo(-SHIP_SIZE * 0.4, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, -SHIP_SIZE * 0.35);
ctx.lineTo(-SHIP_SIZE * 1.3, 0);
ctx.lineTo(-SHIP_SIZE * 0.7, SHIP_SIZE * 0.35);
ctx.fill();
}
ctx.restore();
}
function drawAsteroid(a) {
ctx.save();
ctx.translate(a.x, a.y);
ctx.rotate(a.rot);
ctx.strokeStyle = '#aaa';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(a.verts[0].x, a.verts[0].y);
for (let i = 1; i < a.verts.length; i++) ctx.lineTo(a.verts[i].x, a.verts[i].y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function loop() {
ctx.clearRect(0, 0, W, H);
stars.forEach(s => {
ctx.fillStyle = `rgba(255,255,255,${s.b})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
});
if (state === 'play') {
if (keys['ArrowLeft']) ship.angle -= TURN_SPEED;
if (keys['ArrowRight']) ship.angle += TURN_SPEED;
if (keys['ArrowUp']) {
ship.vx += Math.cos(ship.angle) * THRUST;
ship.vy += Math.sin(ship.angle) * THRUST;
}
ship.vx *= FRICTION; ship.vy *= FRICTION;
ship.x += ship.vx; ship.y += ship.vy;
wrap(ship);
if (ship.cooldown > 0) ship.cooldown--;
if (keys['Space'] && ship.cooldown === 0) {
bullets.push({ x: ship.x + Math.cos(ship.angle) * SHIP_SIZE, y: ship.y + Math.sin(ship.angle) * SHIP_SIZE, vx: Math.cos(ship.angle) * BULLET_SPEED + ship.vx, vy: Math.sin(ship.angle) * BULLET_SPEED + ship.vy, life: 55 });
ship.cooldown = 12;
}
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.x += b.vx; b.y += b.vy; b.life--;
wrap(b);
if (b.life <= 0) { bullets.splice(i, 1); continue; }
let hit = false;
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
const dx = b.x - a.x, dy = b.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.85) {
const pts = a.size > 25 ? 20 : a.size > 14 ? 50 : 100;
score += pts;
document.getElementById('score').textContent = score;
bullets.splice(i, 1);
asteroids.splice(j, 1);
if (a.size > 25) { asteroids.push(randAsteroid(18, a.x + 10, a.y)); asteroids.push(randAsteroid(18, a.x - 10, a.y)); }
else if (a.size > 14) { asteroids.push(randAsteroid(10, a.x, a.y + 10)); asteroids.push(randAsteroid(10, a.x, a.y - 10)); }
hit = true; break;
}
}
if (hit) continue;
}
for (let j = asteroids.length - 1; j >= 0; j--) {
const a = asteroids[j];
a.x += a.vx; a.y += a.vy; a.rot += a.rotSpeed;
wrap(a);
if (invincible <= 0) {
const dx = ship.x - a.x, dy = ship.y - a.y;
if (Math.sqrt(dx * dx + dy * dy) < a.size * 0.75 + SHIP_SIZE * 0.6) {
lives--;
document.getElementById('lives').textContent = lives;
if (lives <= 0) { state = 'over'; document.getElementById('msg').textContent = `GAME OVER! Score: ${score} — SPACE to retry`; document.getElementById('scoreInput').value = score; document.getElementById('scoreSubmit').style.display = 'block'; }
else { ship.x = W / 2; ship.y = H / 2; ship.vx = 0; ship.vy = 0; invincible = 120; }
}
}
}
if (invincible > 0) invincible--;
if (asteroids.length === 0) {
level++;
document.getElementById('level').textContent = level;
spawnAsteroids();
}
frames++;
}
asteroids.forEach(drawAsteroid);
bullets.forEach(b => {
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fill();
});
const alpha = invincible > 0 && frames % 6 < 3 ? 0.3 : 1;
drawShip(ship, alpha);
for (let i = 0; i < lives; i++) {
ctx.save(); ctx.translate(16 + i * 22, H - 18); ctx.rotate(-Math.PI / 2);
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(-6, -5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 5); ctx.closePath(); ctx.stroke();
ctx.restore();
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220">
<rect width="400" height="220" fill="#000"/>
<circle cx="30" cy="20" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="80" cy="45" r="1" fill="#fff" opacity="0.6"/>
<circle cx="120" cy="10" r="2" fill="#fff" opacity="0.9"/>
<circle cx="200" cy="30" r="1" fill="#fff" opacity="0.7"/>
<circle cx="320" cy="15" r="1.5" fill="#fff" opacity="0.8"/>
<circle cx="370" cy="50" r="1" fill="#fff" opacity="0.6"/>
<circle cx="50" cy="180" r="1" fill="#fff" opacity="0.5"/>
<circle cx="350" cy="190" r="1.5" fill="#fff" opacity="0.7"/>
<circle cx="280" cy="8" r="1" fill="#fff" opacity="0.8"/>
<polygon points="85,60 110,45 140,55 155,80 140,110 110,120 75,105 65,80" fill="#888" stroke="#aaa" stroke-width="2"/>
<polygon points="240,30 265,22 290,35 300,60 285,85 255,90 230,75 225,50" fill="#777" stroke="#999" stroke-width="2"/>
<polygon points="310,130 330,118 355,128 365,152 350,172 325,178 305,165 298,142" fill="#999" stroke="#bbb" stroke-width="2"/>
<polygon points="40,130 58,120 75,132 80,155 65,170 43,172 28,160 25,140" fill="#666" stroke="#888" stroke-width="2"/>
<polygon points="200,105 196,95 200,92 204,95 200,108" fill="#FFA500" stroke="#FFA500" stroke-width="1"/>
<polygon points="196,108 192,118 200,112 208,118 204,108" fill="#FFA500"/>
<circle cx="200" cy="96" r="4" fill="#fff" stroke="#FFA500" stroke-width="1.5"/>
<rect x="192" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="204" y="107" width="4" height="10" fill="#ff6600" opacity="0.8"/>
<rect x="197" y="115" width="6" height="14" fill="#ff3300" opacity="0.6"/>
<line x1="200" y1="80" x2="200" y2="50" stroke="#fff" stroke-width="2" opacity="0.8"/>
<circle cx="200" cy="46" r="3" fill="#fff" opacity="0.9"/>
<text x="200" y="215" font-family="monospace" font-size="12" fill="#FFA500" text-anchor="middle">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Musical Double Pendulum</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #1a1a1a;
--border: #333;
--accent: #4a9eff;
--axis: #2b6fff;
--grid: #121212;
--text: #ffffff;
--muted: #888;
--trace1: #ff6b4a;
--trace2: #4aff6b;
--warning: #ff6b4a;
--success: #4aff6b;
--audio: #ff9500;
}
* { box-sizing: border-box; }
body { margin: 0; padding: 14px; font-family: Consolas, monospace; background: var(--bg); color: var(--text); }
.container { display: flex; gap: 14px; align-items: flex-start; }
.simulation-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; flex: 1; min-width: 0;
}
.controls-panel {
background: var(--panel); border: 2px solid var(--border); border-radius: 10px;
padding: 14px; width: 340px; flex-shrink: 0;
}
h3 { margin: 0 0 8px 0; color: var(--accent); font-size: 13px; letter-spacing: 2px; text-transform: uppercase; }
.audio-section { border: 2px solid var(--audio); border-radius: 8px; padding: 10px; margin: 8px 0; background: #1a0f00; }
.audio-section h3 { color: var(--audio); margin-bottom: 8px; }
.status { background:#000; color:var(--accent); padding:7px 10px; border-radius:6px; margin-bottom:7px; font-size:11px; }
.audio-status { background:#1a0f00; color:var(--audio); border: 1px solid var(--audio); }
.toolbar { display:flex; gap:8px; margin-bottom:8px; flex-wrap: wrap; }
button {
padding: 6px 10px; border: 1px solid var(--accent); background: var(--panel); color: var(--accent);
border-radius: 6px; cursor: pointer; transition: all .2s; font-family: inherit; font-size: 12px;
}
button:hover { background: var(--accent); color: #000; }
button.audio-btn { border-color: var(--audio); color: var(--audio); }
button.audio-btn:hover { background: var(--audio); color: #000; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
canvas { background:#000; border:1px solid #444; display:block; width:100%; cursor:grab; }
canvas:active { cursor:grabbing; }
.geometry-info { background:#0a0a2a; color:#99aaff; padding:8px 10px; border-radius:6px; margin-top:8px; font-size:11px; }
.energy-info { background:#0a2a0a; color:#99ff99; padding:8px 10px; border-radius:6px; margin-top:5px; font-size:11px; }
.control-group { margin-bottom: 10px; padding-bottom:10px; border-bottom:1px solid #333; }
.control-group:last-child { border-bottom: none; }
label { display:block; margin-bottom:4px; font-size:11px; color:#ccc; text-transform:uppercase; letter-spacing:1px; }
input[type="range"] { width:100%; margin-bottom:4px; accent-color: var(--accent); }
.audio-control input[type="range"] { accent-color: var(--audio); }
.value-display { background:#000; color:var(--accent); padding:2px 6px; border-radius:3px; font-size:11px; float:right; }
.audio-value { color: var(--audio); }
.state-tag { display: none; font-size: 11px; }
.state-tag.visible { display: inline; }
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; margin-bottom:10px; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">AUDIO PENDULUM</span>
</div>
<div class="container">
<div class="simulation-panel" id="simPanel">
<div class="status" id="status">Status: Stopped | Energy: 0.00 J | Time: 0.00 s</div>
<div class="status audio-status" id="audioStatus">Tonal: Disabled | Percussion: Disabled | ♪: -- Hz | 🥁: 0 hits</div>
<div class="toolbar">
<button id="startBtn">&#9654; Start</button>
<button id="stopBtn">&#9209; Stop</button>
<button id="resetBtn">&#8635; Reset</button>
<button id="clearBtn">Clear Traces</button>
<button id="audioBtn" class="audio-btn">&#128266; Enable Audio</button>
</div>
<canvas id="pendulumCanvas" width="680" height="540"></canvas>
<div class="geometry-info" id="geometryInfo">
<strong>Physical State:</strong><br />
<span id="geoTheta">Initial position: vertical up (12 o'clock)</span><br />
<span id="geoOmega">State: Unstable equilibrium</span><br />
<span id="geoStateNearEq" class="state-tag" style="color:var(--success)">&#10003; NEAR EQUILIBRIUM</span>
<span id="geoStateMoving" class="state-tag visible">In motion</span>
<span id="geoStateRest" class="state-tag" style="color:var(--success)"><br />&#10003; AT REST</span>
<span id="geoStateTonal" class="state-tag" style="color:var(--audio)"><br />&#127925; TONAL AUDIO ACTIVE</span>
<span id="geoStatePerc" class="state-tag" style="color:var(--audio)"><br />&#129345; PERCUSSION ACTIVE</span>
</div>
<div class="energy-info" id="energyInfo">
<strong>Energy Conservation:</strong><br />
<span id="energyLine1">Initial: -- J | Current: -- J</span><br />
<span id="energyLine2">Energy drift: 0.000% | Status: CORRECT</span>
<span id="energySingularity" class="state-tag" style="color:var(--warning)"><br />Singularities: 0</span>
</div>
</div>
<div class="controls-panel">
<div class="control-group">
<label>Pendulum 1 Length (m) <span class="value-display" id="l1Display">1.00</span></label>
<input type="range" id="l1" min="0.2" max="3.0" step="0.05" value="1.0" />
<label>Pendulum 2 Length (m) <span class="value-display" id="l2Display">1.00</span></label>
<input type="range" id="l2" min="0.2" max="3.0" step="0.05" value="1.0" />
</div>
<div class="control-group">
<label>Pendulum 1 Mass (kg) <span class="value-display" id="m1Display">1.00</span></label>
<input type="range" id="m1" min="0.2" max="5.0" step="0.1" value="1.0" />
<label>Pendulum 2 Mass (kg) <span class="value-display" id="m2Display">1.00</span></label>
<input type="range" id="m2" min="0.2" max="5.0" step="0.1" value="1.0" />
</div>
<div class="control-group">
<label>Gravity (m/s²) <span class="value-display" id="gDisplay">9.81</span></label>
<input type="range" id="g" min="0.1" max="20.0" step="0.1" value="9.81" />
<label>Friction / Damping <span class="value-display" id="dampingDisplay">0.10</span></label>
<input type="range" id="damping" min="0" max="2.0" step="0.01" value="0.1" />
</div>
<div class="control-group">
<label>Angular Velocity 1 (rad/s) <span class="value-display" id="w1Display">0.00</span></label>
<input type="range" id="w1" min="-6.0" max="6.0" step="0.1" value="0.0" />
<label>Angular Velocity 2 (rad/s) <span class="value-display" id="w2Display">0.00</span></label>
<input type="range" id="w2" min="-6.0" max="6.0" step="0.1" value="0.0" />
</div>
<div class="audio-section">
<div class="control-group audio-control">
<label>Master Volume <span class="value-display audio-value" id="volumeDisplay">0.30</span></label>
<input type="range" id="volume" min="0" max="1.0" step="0.05" value="0.3" />
<label>Base Freq - Pendulum 1 (Hz) <span class="value-display audio-value" id="freq1Display">440</span></label>
<input type="range" id="freq1" min="200" max="1000" step="10" value="440" />
<label>Base Freq - Pendulum 2 (Hz) <span class="value-display audio-value" id="freq2Display">550</span></label>
<input type="range" id="freq2" min="200" max="1000" step="10" value="550" />
<label>Sensitivity <span class="value-display audio-value" id="sensitivityDisplay">2.0</span></label>
<input type="range" id="sensitivity" min="0.5" max="5.0" step="0.1" value="2.0" />
</div>
<div class="control-group audio-control">
<label>Percussion Volume <span class="value-display audio-value" id="percVolumeDisplay">0.00</span></label>
<input type="range" id="percVolume" min="0" max="1.0" step="0.05" value="0" />
<label>Peak Threshold <span class="value-display audio-value" id="thresholdDisplay">1.0</span></label>
<input type="range" id="threshold" min="0.2" max="3.0" step="0.1" value="1.0" />
<label>Cooldown (ms) <span class="value-display audio-value" id="cooldownDisplay">100</span></label>
<input type="range" id="cooldown" min="50" max="500" step="10" value="100" />
</div>
</div>
<div class="control-group">
<label>Integration &#916;t (s) <span class="value-display" id="dtDisplay">0.005</span></label>
<input type="range" id="dt" min="0.001" max="0.020" step="0.001" value="0.005" />
</div>
</div>
</div>
<script>
class MusicalDoublePendulum {
constructor(canvasId, panelId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.panel = document.getElementById(panelId);
this.width = this.canvas.width;
this.height = this.canvas.height;
this.originX = this.width / 2;
this.originY = this.height / 2;
this.l1 = 1.0; this.l2 = 1.0;
this.m1 = 1.0; this.m2 = 1.0;
this.g = 9.81;
this.damping = 0.1;
this.theta1 = Math.PI;
this.theta2 = 0.0;
this.omega1 = 0.0; this.omega2 = 0.0;
this.running = false;
this.time = 0;
this.dt = 0.005;
this.scale = 100;
this.trace1 = []; this.trace2 = [];
this.maxTraceLength = 3000;
this.dragging = false;
this.dragTarget = null;
this.initialEnergy = 0;
this.currentEnergy = 0;
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.singularityCount = 0;
this.audioContext = null;
this.oscillator1 = null; this.oscillator2 = null;
this.gainNode1 = null; this.gainNode2 = null;
this.masterGain = null;
this.audioEnabled = false;
this.percussionEnabled = false;
this.kickBuffer = null; this.snareBuffer = null;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.triggerCooldown = 0.1;
this.baseFreq1 = 440; this.baseFreq2 = 550;
this.volume = 0.3;
this.sensitivity = 2.0;
this.percussionVolume = 0;
this.peakThreshold = 1.0;
this.currentFreq1 = 0; this.currentFreq2 = 0;
this.kickCount = 0; this.snareCount = 0;
this.setupEventListeners();
this.updateControls();
this.initialEnergy = this.computeEnergy();
requestAnimationFrame(() => { this.resizeCanvas(); });
}
createPercussionSounds() {
if (!this.audioContext) return;
const sr = this.audioContext.sampleRate;
const kickBuf = this.audioContext.createBuffer(1, 0.5 * sr, sr);
const kd = kickBuf.getChannelData(0);
for (let i = 0; i < kd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 15);
kd[i] = Math.sin(2 * Math.PI * 60 * (1 - t * 0.8) * t) * env + (Math.random() - 0.5) * 0.1 * env;
}
this.kickBuffer = kickBuf;
const snareBuf = this.audioContext.createBuffer(1, 0.2 * sr, sr);
const sd = snareBuf.getChannelData(0);
for (let i = 0; i < sd.length; i++) {
const t = i / sr;
const env = Math.exp(-t * 30);
sd[i] = (Math.sin(2 * Math.PI * 200 * t) * 0.3 + (Math.random() - 0.5) * 0.7) * env;
}
this.snareBuffer = snareBuf;
}
playPercussion(type) {
if (!this.percussionEnabled || !this.audioContext) return;
const buf = type === 'kick' ? this.kickBuffer : this.snareBuffer;
if (!buf) return;
const now = this.audioContext.currentTime;
const src = this.audioContext.createBufferSource();
const gain = this.audioContext.createGain();
src.buffer = buf;
gain.gain.setValueAtTime(this.percussionVolume * (type === 'kick' ? 0.8 : 0.6), now);
src.connect(gain);
gain.connect(this.audioContext.destination);
src.start(now);
if (type === 'kick') this.kickCount++; else this.snareCount++;
setTimeout(() => { try { src.disconnect(); gain.disconnect(); } catch (e) {} }, 600);
}
detectPeaks() {
if (!this.percussionEnabled) return;
const now = this.audioContext ? this.audioContext.currentTime : this.time;
const d1 = this.omega1 - this.lastOmega1;
const d2 = this.omega2 - this.lastOmega2;
const t1 = d1 > 0.01 ? 1 : (d1 < -0.01 ? -1 : 0);
const t2 = d2 > 0.01 ? 1 : (d2 < -0.01 ? -1 : 0);
if (this.omega1Trend === 1 && t1 === -1 && Math.abs(this.omega1) > this.peakThreshold && now - this.lastTrigger1 > this.triggerCooldown) {
this.playPercussion('kick');
this.lastTrigger1 = now;
this.flashPercStatus('kick');
}
if (this.omega2Trend === 1 && t2 === -1 && Math.abs(this.omega2) > this.peakThreshold && now - this.lastTrigger2 > this.triggerCooldown) {
this.playPercussion('snare');
this.lastTrigger2 = now;
this.flashPercStatus('snare');
}
this.lastOmega1 = this.omega1; this.lastOmega2 = this.omega2;
this.omega1Trend = t1; this.omega2Trend = t2;
}
flashPercStatus(type) {
const el = document.getElementById(type === 'kick' ? 'kick-status' : 'snare-status');
if (!el) return;
const orig = el.textContent;
el.textContent = type === 'kick' ? '\u{1F941} Kick: HIT!' : '\u{1F514} Snare: HIT!';
el.style.color = type === 'kick' ? '#ff6b4a' : '#4aff6b';
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 150);
}
async initAudio() {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.audioContext.createGain();
this.masterGain.connect(this.audioContext.destination);
this.masterGain.gain.setValueAtTime(this.volume, this.audioContext.currentTime);
this.oscillator1 = this.audioContext.createOscillator();
this.gainNode1 = this.audioContext.createGain();
this.oscillator1.connect(this.gainNode1);
this.gainNode1.connect(this.masterGain);
this.oscillator2 = this.audioContext.createOscillator();
this.gainNode2 = this.audioContext.createGain();
this.oscillator2.connect(this.gainNode2);
this.gainNode2.connect(this.masterGain);
this.oscillator1.type = 'sine';
this.oscillator2.type = 'triangle';
this.oscillator1.frequency.setValueAtTime(this.baseFreq1, this.audioContext.currentTime);
this.oscillator2.frequency.setValueAtTime(this.baseFreq2, this.audioContext.currentTime);
this.gainNode1.gain.setValueAtTime(0, this.audioContext.currentTime);
this.gainNode2.gain.setValueAtTime(0, this.audioContext.currentTime);
this.oscillator1.start();
this.oscillator2.start();
this.audioEnabled = true;
return true;
} catch (e) {
return false;
}
}
updateAudio() {
if (!this.audioEnabled || !this.audioContext) return;
const now = this.audioContext.currentTime;
this.currentFreq1 = Math.max(100, Math.min(2000, this.baseFreq1 * (1 + Math.abs(this.omega1) * this.sensitivity * 0.1)));
this.currentFreq2 = Math.max(100, Math.min(2000, this.baseFreq2 * (1 + Math.abs(this.omega2) * this.sensitivity * 0.1)));
this.oscillator1.frequency.setTargetAtTime(this.currentFreq1, now, 0.01);
this.oscillator2.frequency.setTargetAtTime(this.currentFreq2, now, 0.01);
this.gainNode1.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega1) * 0.1) * this.volume, now, 0.01);
this.gainNode2.gain.setTargetAtTime(Math.min(0.5, Math.abs(this.omega2) * 0.1) * this.volume, now, 0.01);
}
stopAudio() {
if (this.audioContext && this.audioEnabled) {
try { this.oscillator1.stop(); this.oscillator2.stop(); this.audioContext.close(); } catch (e) {}
}
this.audioEnabled = false;
this.audioContext = null;
}
setupEventListeners() {
const controls = ['l1','l2','m1','m2','g','damping','w1','w2','dt','volume','freq1','freq2','sensitivity','percVolume','threshold','cooldown'];
controls.forEach(id => {
document.getElementById(id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (id === 'w1') this.omega1 = v;
else if (id === 'w2') this.omega2 = v;
else if (id === 'dt') this.dt = v;
else if (id === 'volume') this.volume = v;
else if (id === 'freq1') this.baseFreq1 = v;
else if (id === 'freq2') this.baseFreq2 = v;
else if (id === 'sensitivity') this.sensitivity = v;
else if (id === 'percVolume') {
this.percussionVolume = v;
if (v > 0 && !this.percussionEnabled) {
if (!this.audioContext) this.initAudio().then(ok => { if (ok) { this.createPercussionSounds(); this.percussionEnabled = true; } });
else { this.createPercussionSounds(); this.percussionEnabled = true; }
} else if (v === 0) {
this.percussionEnabled = false;
}
}
else if (id === 'threshold') this.peakThreshold = v;
else if (id === 'cooldown') this.triggerCooldown = v / 1000;
else this[id] = v;
this.updateControls();
if (id === 'l1' || id === 'l2') this.updateScale();
if (this.audioEnabled && this.masterGain && (id === 'volume' || id === 'freq1' || id === 'freq2' || id === 'sensitivity')) {
this.masterGain.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, 0.1);
}
if (!this.running) { this.initialEnergy = this.computeEnergy(); this.maxEnergyDrift = 0; this.energyHistory = []; }
if (!this.running) this.draw();
});
});
document.getElementById('startBtn').addEventListener('click', () => this.start());
document.getElementById('stopBtn').addEventListener('click', () => this.stop());
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
document.getElementById('clearBtn').addEventListener('click', () => this.clearTraces());
document.getElementById('audioBtn').addEventListener('click', async () => {
const btn = document.getElementById('audioBtn');
if (!this.audioEnabled) {
if (await this.initAudio()) {
btn.textContent = 'Disable Audio';
btn.style.background = '#ff9500';
btn.style.color = '#000';
}
} else {
this.stopAudio();
btn.textContent = '\u{1F50A} Enable Audio';
btn.style.background = '';
btn.style.color = '#ff9500';
}
});
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', () => this.onMouseUp());
this.canvas.addEventListener('mouseleave', () => this.onMouseUp());
window.addEventListener('resize', () => this.resizeCanvas());
}
updateControls() {
const set = (id, v, d=2) => { const el = document.getElementById(id); if (el) el.textContent = v.toFixed(d); };
set('l1Display', this.l1); set('l2Display', this.l2);
set('m1Display', this.m1); set('m2Display', this.m2);
set('gDisplay', this.g); set('dampingDisplay', this.damping);
set('w1Display', this.omega1); set('w2Display', this.omega2);
set('dtDisplay', this.dt, 3);
set('volumeDisplay', this.volume);
const f1el = document.getElementById('freq1Display'); if (f1el) f1el.textContent = this.baseFreq1.toFixed(0);
const f2el = document.getElementById('freq2Display'); if (f2el) f2el.textContent = this.baseFreq2.toFixed(0);
set('sensitivityDisplay', this.sensitivity, 1);
set('percVolumeDisplay', this.percussionVolume);
set('thresholdDisplay', this.peakThreshold, 1);
const cdel = document.getElementById('cooldownDisplay'); if (cdel) cdel.textContent = (this.triggerCooldown * 1000).toFixed(0);
}
resizeCanvas() {
const w = this.canvas.clientWidth;
if (w < 10) return;
this.canvas.width = w;
this.canvas.height = Math.round(w * (540 / 680));
this.updateCanvasMetrics();
}
getMousePos(e) {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
onMouseDown(e) {
if (this.running) return;
const m = this.getMousePos(e);
const p1 = this.getPendulumPosition(1);
const p2 = this.getPendulumPosition(2);
if (Math.hypot(m.x - p1.x, m.y - p1.y) < 20) { this.dragging = true; this.dragTarget = 1; }
else if (Math.hypot(m.x - p2.x, m.y - p2.y) < 20) { this.dragging = true; this.dragTarget = 2; }
}
onMouseMove(e) {
if (!this.dragging || this.running) return;
const m = this.getMousePos(e);
if (this.dragTarget === 1) {
this.theta1 = Math.atan2(m.x - this.originX, m.y - this.originY);
} else {
const p1 = this.getPendulumPosition(1);
this.theta2 = Math.atan2(m.x - p1.x, m.y - p1.y) - this.theta1;
}
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.energyHistory = [];
this.draw();
}
onMouseUp() { this.dragging = false; this.dragTarget = null; }
updateCanvasMetrics() {
this.width = this.canvas.width; this.height = this.canvas.height;
this.originX = this.width / 2; this.originY = this.height / 2;
this.updateScale();
if (!this.running) this.draw();
}
updateScale() {
const half = 0.5 * Math.min(this.width, this.height) * 0.9;
const len = this.l1 + this.l2;
this.scale = len > 0 ? half / len : 100;
}
getPendulumPosition(n) {
if (n === 1) return {
x: this.originX + this.l1 * this.scale * Math.sin(this.theta1),
y: this.originY + this.l1 * this.scale * Math.cos(this.theta1)
};
const p1 = this.getPendulumPosition(1);
return {
x: p1.x + this.l2 * this.scale * Math.sin(this.theta1 + this.theta2),
y: p1.y + this.l2 * this.scale * Math.cos(this.theta1 + this.theta2)
};
}
accelerations() {
const { m1, m2, l1, l2, g } = this;
const th1 = this.theta1, th2 = this.theta2, w1 = this.omega1, w2 = this.omega2;
const s12 = Math.sin(th1 - th2), c12 = Math.cos(th1 - th2);
const den = 2*m1 + m2 - m2 * Math.cos(2*th1 - 2*th2);
if (Math.abs(den) < 1e-8) {
this.singularityCount++;
return { a1: -0.1*w1 - 0.5*th1, a2: -0.1*w2 - 0.5*th2 };
}
let a1 = (-g*(2*m1+m2)*Math.sin(th1) - m2*g*Math.sin(th1-2*th2) - 2*s12*m2*(w2*w2*l2 + w1*w1*l1*c12)) / (l1*den);
let a2 = (2*s12*(w1*w1*l1*(m1+m2) + g*(m1+m2)*Math.cos(th1) + w2*w2*l2*m2*c12)) / (l2*den);
if (this.damping > 0) {
const vx1 = l1*w1*Math.cos(th1), vy1 = -l1*w1*Math.sin(th1);
const vx2 = vx1 + l2*(w1+w2)*Math.cos(th1+th2), vy2 = vy1 - l2*(w1+w2)*Math.sin(th1+th2);
const dr = this.damping * 0.05;
const v1sq = vx1*vx1+vy1*vy1, v2sq = vx2*vx2+vy2*vy2;
if (v1sq > 1e-6) a1 += -dr * v1sq * Math.sign(w1) / l1;
if (v2sq > 1e-6) a2 += -dr * v2sq * Math.sign(w2) / l2;
const jf = this.damping * 0.3;
a1 -= jf*(w1 + 0.5*th1); a2 -= jf*(w2 + 0.5*th2);
if (this.damping > 1.0) { const hd = (this.damping-1.0)*2.0; a1 -= hd*w1; a2 -= hd*w2; }
}
return { a1, a2 };
}
rk4Step(dt) {
const deriv = (th1, th2, w1, w2) => {
const [st1, st2, sw1, sw2] = [this.theta1, this.theta2, this.omega1, this.omega2];
this.theta1=th1; this.theta2=th2; this.omega1=w1; this.omega2=w2;
const {a1,a2} = this.accelerations();
this.theta1=st1; this.theta2=st2; this.omega1=sw1; this.omega2=sw2;
return {dth1:w1, dth2:w2, dw1:a1, dw2:a2};
};
const s = {th1:this.theta1, th2:this.theta2, w1:this.omega1, w2:this.omega2};
const k1=deriv(s.th1,s.th2,s.w1,s.w2);
const k2=deriv(s.th1+.5*dt*k1.dth1, s.th2+.5*dt*k1.dth2, s.w1+.5*dt*k1.dw1, s.w2+.5*dt*k1.dw2);
const k3=deriv(s.th1+.5*dt*k2.dth1, s.th2+.5*dt*k2.dth2, s.w1+.5*dt*k2.dw1, s.w2+.5*dt*k2.dw2);
const k4=deriv(s.th1+dt*k3.dth1, s.th2+dt*k3.dth2, s.w1+dt*k3.dw1, s.w2+dt*k3.dw2);
this.theta1 += (dt/6)*(k1.dth1+2*k2.dth1+2*k3.dth1+k4.dth1);
this.theta2 += (dt/6)*(k1.dth2+2*k2.dth2+2*k3.dth2+k4.dth2);
this.omega1 += (dt/6)*(k1.dw1+2*k2.dw1+2*k3.dw1+k4.dw1);
this.omega2 += (dt/6)*(k1.dw2+2*k2.dw2+2*k3.dw2+k4.dw2);
}
update() {
if (!this.running) return;
this.rk4Step(this.dt);
this.updateAudio();
this.detectPeaks();
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.trace1.push({x:p1.x, y:p1.y}); this.trace2.push({x:p2.x, y:p2.y});
if (this.trace1.length > this.maxTraceLength) this.trace1.shift();
if (this.trace2.length > this.maxTraceLength) this.trace2.shift();
this.currentEnergy = this.computeEnergy();
this.energyHistory.push(this.currentEnergy);
if (this.energyHistory.length > 1000) this.energyHistory.shift();
this.maxEnergyDrift = Math.max(this.maxEnergyDrift, Math.abs(this.currentEnergy - this.initialEnergy));
this.time += this.dt;
this.updateStatus();
}
computeEnergy() {
const vx1 = this.l1*this.omega1*Math.cos(this.theta1);
const vy1 = -this.l1*this.omega1*Math.sin(this.theta1);
const vx2 = vx1 + this.l2*(this.omega1+this.omega2)*Math.cos(this.theta1+this.theta2);
const vy2 = vy1 - this.l2*(this.omega1+this.omega2)*Math.sin(this.theta1+this.theta2);
const T = 0.5*this.m1*(vx1*vx1+vy1*vy1) + 0.5*this.m2*(vx2*vx2+vy2*vy2);
const V = this.m1*this.g*this.l1*(1-Math.cos(this.theta1)) +
this.m2*this.g*(this.l1*(1-Math.cos(this.theta1)) + this.l2*(1-Math.cos(this.theta1+this.theta2)));
return T + V;
}
setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
setVisible(id, v) { const el = document.getElementById(id); if (el) el.classList.toggle('visible', v); }
updateStatus() {
const state = this.running ? 'Running' : 'Stopped';
this.setText('status', 'Status: ' + state + ' | Energy: ' + this.currentEnergy.toFixed(3) + ' J | Time: ' + this.time.toFixed(2) + ' s');
const f1 = this.audioEnabled ? this.currentFreq1.toFixed(0) : '--';
const f2 = this.audioEnabled ? this.currentFreq2.toFixed(0) : '--';
this.setText('audioStatus',
'Tonal: ' + (this.audioEnabled ? 'Active' : 'Disabled') +
' | Percussion: ' + (this.percussionEnabled ? 'Active' : 'Disabled') +
' | \u266a: ' + f1 + '/' + f2 + ' Hz | \u{1F941}: ' + (this.kickCount + this.snareCount) + ' hits');
const driftPct = this.initialEnergy !== 0 ? (this.maxEnergyDrift / Math.abs(this.initialEnergy)) * 100 : 0;
let energyStatus = 'CORRECT';
if (driftPct > 1.0) energyStatus = 'HIGH DRIFT';
else if (driftPct > 0.1) energyStatus = 'MINOR DRIFT';
this.setText('energyLine1', 'Initial: ' + this.initialEnergy.toFixed(4) + ' J | Current: ' + this.currentEnergy.toFixed(4) + ' J');
this.setText('energyLine2', 'Energy drift: ' + driftPct.toFixed(3) + '% | Status: ' + energyStatus);
this.setVisible('energySingularity', this.singularityCount > 0);
if (this.singularityCount > 0) this.setText('energySingularity', 'Singularities: ' + this.singularityCount);
this.setText('geoTheta', '\u03b8\u2081=' + this.theta1.toFixed(3) + ' rad, \u03b8\u2082=' + this.theta2.toFixed(3) + ' rad');
this.setText('geoOmega', '\u03c9\u2081=' + this.omega1.toFixed(3) + ' rad/s, \u03c9\u2082=' + this.omega2.toFixed(3) + ' rad/s');
const vel = Math.sqrt(this.omega1*this.omega1 + this.omega2*this.omega2);
const nearEq = Math.abs(this.theta1) < 0.1 && Math.abs(this.theta2) < 0.1 && vel < 0.1;
this.setVisible('geoStateNearEq', nearEq);
this.setVisible('geoStateMoving', !nearEq);
this.setVisible('geoStateRest', this.damping > 1.0 && vel < 0.01);
this.setVisible('geoStateTonal', this.audioEnabled && vel > 0.1);
this.setVisible('geoStatePerc', this.percussionEnabled && vel > 0.1);
}
draw() {
this.ctx.clearRect(0, 0, this.width, this.height);
this.drawGridAndAxes();
this.drawTraces();
this.drawPendulum();
this.drawInfo();
}
drawGridAndAxes() {
this.ctx.strokeStyle = '#121212'; this.ctx.lineWidth = 1;
for (let x = 0; x <= this.width; x += 40) { this.ctx.beginPath(); this.ctx.moveTo(x,0); this.ctx.lineTo(x,this.height); this.ctx.stroke(); }
for (let y = 0; y <= this.height; y += 40) { this.ctx.beginPath(); this.ctx.moveTo(0,y); this.ctx.lineTo(this.width,y); this.ctx.stroke(); }
this.ctx.strokeStyle = '#2b6fff'; this.ctx.lineWidth = 2;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,0); this.ctx.lineTo(this.originX,this.height); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(0,this.originY); this.ctx.lineTo(this.width,this.originY); this.ctx.stroke();
this.ctx.fillStyle = '#4a9eff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,5,0,Math.PI*2); this.ctx.fill();
this.ctx.fillStyle = '#fff'; this.ctx.beginPath(); this.ctx.arc(this.originX,this.originY,2,0,Math.PI*2); this.ctx.fill();
}
drawTraces() {
const drawTrace = (trace, color, lw, alpha) => {
if (trace.length < 2) return;
this.ctx.strokeStyle = color; this.ctx.lineWidth = lw; this.ctx.globalAlpha = alpha;
this.ctx.beginPath(); this.ctx.moveTo(trace[0].x, trace[0].y);
for (let i = 1; i < trace.length; i++) this.ctx.lineTo(trace[i].x, trace[i].y);
this.ctx.stroke();
};
drawTrace(this.trace1, '#ff6b4a', 1.5, 0.7);
drawTrace(this.trace2, '#4aff6b', 2.5, 0.9);
this.ctx.globalAlpha = 1.0;
}
drawPendulum() {
const p1 = this.getPendulumPosition(1), p2 = this.getPendulumPosition(2);
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 4;
this.ctx.beginPath(); this.ctx.moveTo(this.originX,this.originY); this.ctx.lineTo(p1.x,p1.y); this.ctx.stroke();
this.ctx.beginPath(); this.ctx.moveTo(p1.x,p1.y); this.ctx.lineTo(p2.x,p2.y); this.ctx.stroke();
const r1 = Math.max(6, 4+this.m1*4), r2 = Math.max(6, 4+this.m2*4);
const drawBob = (px, py, r, color, omega) => {
if (this.audioEnabled && this.running) {
const intensity = Math.min(1, Math.abs(omega) * 0.2);
if (intensity > 0.1) { this.ctx.shadowBlur = 15*intensity; this.ctx.shadowColor = color; }
}
this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.fill();
this.ctx.strokeStyle = '#fff'; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(px,py,r,0,Math.PI*2); this.ctx.stroke();
this.ctx.shadowBlur = 0;
};
drawBob(p1.x, p1.y, r1, '#ff6b4a', this.omega1);
drawBob(p2.x, p2.y, r2, '#4aff6b', this.omega2);
}
drawInfo() {
this.ctx.fillStyle = '#4a9eff'; this.ctx.font = '13px Consolas';
this.ctx.fillText('\u03b8\u2081: ' + this.theta1.toFixed(3) + ' rad', 10, 20);
this.ctx.fillText('\u03b8\u2082: ' + this.theta2.toFixed(3) + ' rad', 10, 36);
this.ctx.fillText('\u03c9\u2081: ' + this.omega1.toFixed(3) + ' rad/s', 10, 52);
this.ctx.fillText('\u03c9\u2082: ' + this.omega2.toFixed(3) + ' rad/s', 10, 68);
let y = 84;
if (this.audioEnabled && this.running) {
this.ctx.fillStyle = '#ff9500';
this.ctx.fillText('\u266a1: ' + this.currentFreq1.toFixed(0) + ' Hz', 10, y);
this.ctx.fillText('\u266a2: ' + this.currentFreq2.toFixed(0) + ' Hz', 10, y+16);
y += 32;
}
if (this.percussionEnabled && this.running) {
this.ctx.fillStyle = '#ff6b4a';
this.ctx.fillText('\u{1F941}: ' + this.kickCount + ' hits', 10, y);
this.ctx.fillStyle = '#4aff6b';
this.ctx.fillText('\u{1F514}: ' + this.snareCount + ' hits', 10, y+16);
y += 32;
}
if (this.damping > 0) {
this.ctx.fillStyle = this.damping > 1.0 ? '#ff6b4a' : '#ffaa4a';
this.ctx.fillText('Friction: ' + this.damping.toFixed(2), 10, y);
}
this.ctx.fillStyle = '#888'; this.ctx.font = '11px Consolas';
this.ctx.fillText('Scale: ' + this.scale.toFixed(1) + ' px/m', 10, this.height-10);
}
start() {
if (!this.running) {
this.running = true;
this.initialEnergy = this.computeEnergy();
this.maxEnergyDrift = 0;
this.animate();
}
}
stop() {
this.running = false;
if (this.audioEnabled) {
this.gainNode1.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
this.gainNode2.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
}
}
reset() {
this.stop();
this.theta1 = Math.PI; this.theta2 = 0.0;
this.omega1 = 0; this.omega2 = 0;
this.time = 0;
this.clearTraces();
this.initialEnergy = this.computeEnergy();
this.currentEnergy = this.initialEnergy;
this.maxEnergyDrift = 0; this.energyHistory = []; this.singularityCount = 0;
this.kickCount = 0; this.snareCount = 0;
this.lastTrigger1 = 0; this.lastTrigger2 = 0;
this.lastOmega1 = 0; this.lastOmega2 = 0;
this.omega1Trend = 0; this.omega2Trend = 0;
this.resizeCanvas();
}
clearTraces() { this.trace1 = []; this.trace2 = []; if (!this.running) this.draw(); }
animate() {
if (!this.running) return;
this.update(); this.draw();
requestAnimationFrame(() => this.animate());
}
}
const pendulum = new MusicalDoublePendulum('pendulumCanvas', 'simPanel');
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="6" fill="#0a0a0a" stroke="#1a1a1a" stroke-width="1"/>
<line x1="200" y1="30" x2="200" y2="220" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<line x1="10" y1="110" x2="390" y2="110" stroke="#2b6fff" stroke-width="1" opacity="0.4"/>
<circle cx="200" cy="50" r="5" fill="#4a9eff"/>
<circle cx="200" cy="50" r="2" fill="#fff"/>
<line x1="200" y1="50" x2="148" y2="128" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<line x1="148" y1="128" x2="220" y2="185" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<circle cx="148" cy="128" r="10" fill="#ff6b4a"/>
<circle cx="148" cy="128" r="10" fill="none" stroke="#fff" stroke-width="1.5"/>
<circle cx="220" cy="185" r="12" fill="#4aff6b"/>
<circle cx="220" cy="185" r="12" fill="none" stroke="#fff" stroke-width="1.5"/>
<path d="M 200 50 Q 175 90 148 128 Q 130 155 120 170 Q 110 185 130 192 Q 155 200 175 185 Q 200 168 220 152 Q 245 135 255 118 Q 265 100 248 88 Q 228 76 210 88 Q 192 100 178 115" stroke="#ff6b4a" stroke-width="1.2" fill="none" opacity="0.6"/>
<path d="M 148 128 Q 170 148 195 162 Q 218 175 232 180 Q 248 184 255 175 Q 265 163 258 148 Q 250 132 235 125 Q 218 117 205 122 Q 190 128 182 138 Q 172 150 168 162" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.75"/>
<path d="M 270 85 Q 278 78 278 85 Q 278 92 270 92" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 278 80 Q 290 72 290 85 Q 290 98 278 90" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.7"/>
<path d="M 290 75 Q 305 65 305 85 Q 305 105 290 95" stroke="#ff9500" stroke-width="1.5" fill="none" opacity="0.5"/>
<path d="M 285 128 Q 293 121 293 128 Q 293 135 285 135" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.9"/>
<path d="M 293 123 Q 305 115 305 128 Q 305 141 293 133" stroke="#4aff6b" stroke-width="1.5" fill="none" opacity="0.7"/>
<text x="200" y="213" font-family="'Courier New', monospace" font-size="13" fill="#ff9500" text-anchor="middle" font-weight="bold" letter-spacing="3">AUDIOPENDULUM</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoland</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 8px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOLAND</span>
</div>
<div id="ui">
<span>SCORE: <b id="score">0</b></span>
<span>BEST: <b id="best">0</b></span>
</div>
<canvas id="c" width="600" height="250"></canvas>
<div id="msg">Press SPACE or TAP to start</div>
<div id="controls">SPACE / TAP — Jump &nbsp;|&nbsp; Double jump allowed</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoland">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const msgEl = document.getElementById('msg');
const W = canvas.width, H = canvas.height;
const GROUND = H - 40;
const GRAVITY = 0.6;
const JUMP = -13;
let state = 'idle';
let score = 0, best = 0, frames = 0;
let coco, obstacles, coins, speed;
function reset() {
coco = { x: 80, y: GROUND - 44, vy: 0, r: 22, jumps: 0 };
obstacles = [];
coins = [];
speed = 2;
score = 0;
frames = 0;
scoreEl.textContent = '0';
}
function jump() {
if (state === 'idle' || state === 'over') {
reset();
state = 'play';
msgEl.textContent = '';
return;
}
if (coco.jumps < 2) {
coco.vy = JUMP;
coco.jumps++;
}
}
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); jump(); } });
canvas.addEventListener('click', jump);
function spawnObstacle() {
const h = 30 + Math.random() * 40;
obstacles.push({ x: W, y: GROUND - h, w: 22, h });
}
function spawnCoin() {
const y = GROUND - 60 - Math.random() * 80;
coins.push({ x: W, y, r: 9, collected: false });
}
function drawCoco() {
const x = coco.x, y = coco.y;
ctx.fillStyle = '#8B4513';
ctx.beginPath();
ctx.arc(x, y + coco.r, coco.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(x - 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 8, y + coco.r - 6, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(x - 7, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x + 9, y + coco.r - 6, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#5a2000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y + coco.r + 7, 8, 0.1 * Math.PI, 0.9 * Math.PI);
ctx.stroke();
ctx.fillStyle = '#3a8000';
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x - 14, y - 14, x - 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + 14, y - 14, x + 8, y - 24);
ctx.quadraticCurveTo(x, y - 10, x, y);
ctx.fill();
}
function drawPalmTree(px, groundY) {
ctx.strokeStyle = '#4a7a00';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(px, groundY);
ctx.quadraticCurveTo(px + 6, groundY - 60, px - 4, groundY - 110);
ctx.stroke();
ctx.fillStyle = '#3a8000';
for (let a = -0.6; a <= 0.7; a += 0.3) {
ctx.beginPath();
ctx.ellipse(px - 4 + Math.cos(a) * 32, groundY - 110 + Math.sin(a) * 12 - 10, 30, 10, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = '#8B4513';
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(px - 4 + (i - 1) * 9, groundY - 115, 6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawCoin(c) {
if (c.collected) return;
ctx.fillStyle = '#FFA500';
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('E', c.x, c.y);
}
function collides(ax, ay, aw, ah, bx, by, bw, bh) {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
let bgOffset = 0;
let palmTimer = 0, coinTimer = 0;
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#1a3a00';
ctx.fillRect(0, GROUND, W, H - GROUND);
if (state === 'play') {
bgOffset = (bgOffset + speed * 0.5) % W;
palmTimer += speed;
coinTimer += speed;
if (palmTimer > 350 + Math.random() * 150) {
spawnObstacle();
palmTimer = 0;
}
if (coinTimer > 150 + Math.random() * 80) {
spawnCoin();
coinTimer = 0;
}
}
for (let i = obstacles.length - 1; i >= 0; i--) {
const o = obstacles[i];
if (state === 'play') o.x -= speed;
if (o.x + o.w < 0) { obstacles.splice(i, 1); continue; }
drawPalmTree(o.x + 11, GROUND);
if (state === 'play' && collides(coco.x - coco.r + 4, coco.y, coco.r * 2 - 8, coco.r * 2, o.x, o.y, o.w, o.h)) {
state = 'over';
if (score > best) { best = score; bestEl.textContent = best; }
msgEl.textContent = 'GAME OVER — Press SPACE to retry';
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
}
for (let i = coins.length - 1; i >= 0; i--) {
const c = coins[i];
if (state === 'play') c.x -= speed;
if (c.x + c.r < 0) { coins.splice(i, 1); continue; }
drawCoin(c);
if (!c.collected && state === 'play') {
const dx = coco.x - c.x, dy = (coco.y + coco.r) - c.y;
if (Math.sqrt(dx * dx + dy * dy) < coco.r + c.r) {
c.collected = true;
score += 10;
scoreEl.textContent = score;
}
}
}
if (state === 'play') {
coco.vy += GRAVITY;
coco.y += coco.vy;
if (coco.y >= GROUND - coco.r * 2) {
coco.y = GROUND - coco.r * 2;
coco.vy = 0;
coco.jumps = 0;
}
frames++;
if (frames % 6 === 0) {
score++;
scoreEl.textContent = score;
}
if (frames % 60 === 0 && speed < 10) speed += 0.1;
}
drawCoco();
requestAnimationFrame(loop);
}
reset();
loop();
</script>
</body>
</html>

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="0" y="180" width="400" height="40" fill="#1a3a00"/>
<line x1="300" y1="180" x2="280" y2="50" stroke="#4a7a00" stroke-width="8" stroke-linecap="round"/>
<ellipse cx="265" cy="55" rx="50" ry="30" fill="#2d6a00"/>
<ellipse cx="285" cy="45" rx="45" ry="25" fill="#3a8000"/>
<ellipse cx="275" cy="35" rx="40" ry="20" fill="#4a9400"/>
<circle cx="268" cy="62" r="8" fill="#8B4513"/>
<circle cx="282" cy="65" r="7" fill="#8B4513"/>
<circle cx="275" cy="58" r="7" fill="#8B4513"/>
<line x1="100" y1="180" x2="90" y2="90" stroke="#4a7a00" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="80" cy="95" rx="35" ry="22" fill="#2d6a00"/>
<ellipse cx="95" cy="87" rx="30" ry="18" fill="#3a8000"/>
<circle cx="88" cy="100" r="6" fill="#8B4513"/>
<circle cx="100" cy="102" r="5" fill="#8B4513"/>
<circle cx="58" cy="150" r="22" fill="#8B4513"/>
<circle cx="48" cy="143" r="7" fill="#fff"/>
<circle cx="68" cy="143" r="7" fill="#fff"/>
<circle cx="50" cy="143" r="4" fill="#222"/>
<circle cx="70" cy="143" r="4" fill="#222"/>
<path d="M48 158 Q58 164 68 158" stroke="#5a2000" stroke-width="2" fill="none" stroke-linecap="round"/>
<polygon points="150,165 158,145 166,165" fill="#FFA500"/>
<polygon points="190,170 198,150 206,170" fill="#FFA500"/>
<polygon points="230,162 238,142 246,162" fill="#FFA500"/>
<text x="200" y="210" font-family="monospace" font-size="13" fill="#FFA500" text-anchor="middle" opacity="0.7">SCORE: 0</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cocoman</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">COCOMAN</span>
</div>
<div id="ui">
<span>SCORE: <b id="scoreEl">0</b></span>
<span>LIVES: <b id="livesEl">3</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap arrow to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="cocoman">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 20;
const MAP = [
'############################',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#o# #.# #.##.# #.# #o#',
'#.####.#####.##.#####.####.#',
'#..........................#',
'#.####.##.########.##.####.#',
'#.####.##.########.##.####.#',
'#......##....##....##......#',
'######.##### ## #####.######',
' #.##### ## #####.# ',
' #.## G ##.# ',
' #.## ###=### ##.# ',
'######.## # # ##.######',
' . # GGG # . ',
'######.## # # ##.######',
' #.## ####### ##.# ',
' #.## ##.# ',
' #.## ######## ##.# ',
'######.## ######## ##.######',
'#............##............#',
'#.####.#####.##.#####.####.#',
'#.####.#####.##.#####.####.#',
'#o..##....... .......##..o#',
'###.##.##.########.##.##.###',
'###.##.##.########.##.##.###',
'#......##....##....##......#',
'#.##########.##.##########.#',
'#.##########.##.##########.#',
'#..........................#',
'############################'
];
const COLS = MAP[0].length, ROWS = MAP.length;
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
const GHOST_COLORS = ['#f00', '#f9a', '#0ff', '#fa0'];
let score = 0, lives = 3, gameState = 'idle', comboGhost = 0;
let best = parseInt(localStorage.getItem('cocoman_best') || '-1');
let dots = [], powers = [], player, ghosts, nextDir = [1, 0], frightTimer = 0;
let tickInterval = null;
function initLevel() {
dots = []; powers = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '.') dots.push([c, r]);
if (MAP[r][c] === 'o') powers.push([c, r]);
}
}
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0];
ghosts = [
{ x: 13, y: 14, dir: [0, -1], color: GHOST_COLORS[0] },
{ x: 14, y: 14, dir: [0, 1], color: GHOST_COLORS[1] },
{ x: 13, y: 15, dir: [1, 0], color: GHOST_COLORS[2] },
{ x: 14, y: 15, dir: [-1, 0], color: GHOST_COLORS[3] }
];
frightTimer = 0; comboGhost = 0;
}
function wrapX(x) {
if (x < 0) return COLS - 1;
if (x >= COLS) return 0;
return x;
}
function cellAt(x, y) {
if (y < 0 || y >= ROWS) return '#';
const row = MAP[Math.floor(y)];
const ch = row[Math.floor(x)];
return ch === undefined ? ' ' : ch;
}
function canMovePlayer(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
const ch = cellAt(nx, ny);
return ch !== '#' && ch !== '=';
}
function canGhostMove(x, y, dx, dy) {
const nx = wrapX(x + dx), ny = y + dy;
return cellAt(nx, ny) !== '#';
}
function moveGhost(g) {
const dirs = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => {
if (!canGhostMove(g.x, g.y, dx, dy)) return false;
if (dx === -g.dir[0] && dy === -g.dir[1]) return false;
return true;
});
if (!dirs.length) return;
if (frightTimer > 0) {
g.dir = dirs[Math.floor(Math.random() * dirs.length)];
} else {
dirs.sort((a, b) =>
Math.hypot(g.x+a[0]-player.x, g.y+a[1]-player.y) -
Math.hypot(g.x+b[0]-player.x, g.y+b[1]-player.y)
);
g.dir = dirs[0];
}
g.x = wrapX(g.x + g.dir[0]);
g.y += g.dir[1];
}
function tick() {
if (gameState !== 'play') return;
if (canMovePlayer(player.x, player.y, ...nextDir)) player.dir = [...nextDir];
if (canMovePlayer(player.x, player.y, ...player.dir)) {
player.x = wrapX(player.x + player.dir[0]);
player.y += player.dir[1];
}
const di = dots.findIndex(d => d[0] === player.x && d[1] === player.y);
if (di >= 0) { dots.splice(di, 1); score += 10; document.getElementById('scoreEl').textContent = score; }
const pi = powers.findIndex(p => p[0] === player.x && p[1] === player.y);
if (pi >= 0) { powers.splice(pi, 1); score += 50; frightTimer = 22; comboGhost = 0; document.getElementById('scoreEl').textContent = score; }
if (frightTimer > 0) frightTimer--;
ghosts.forEach(moveGhost);
for (const g of ghosts) {
if (g.x === player.x && g.y === player.y) {
if (frightTimer > 0) {
comboGhost++;
score += 200 * Math.pow(2, comboGhost - 1);
document.getElementById('scoreEl').textContent = score;
g.x = 13; g.y = 14; g.dir = [0, -1];
} else {
loseLife();
return;
}
}
}
if (!dots.length && !powers.length) winGame();
}
function loseLife() {
lives--;
document.getElementById('livesEl').textContent = lives;
if (lives <= 0) {
endGame();
} else {
player = { x: 14, y: 23, dir: [1, 0] };
nextDir = [1, 0]; frightTimer = 0;
document.getElementById('msg').textContent = 'Caught! Keep going...';
}
}
function winGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
score += lives * 100;
document.getElementById('scoreEl').textContent = score;
document.getElementById('msg').textContent = `You won! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function endGame() {
clearInterval(tickInterval); tickInterval = null;
gameState = 'over';
document.getElementById('msg').textContent = `GAME OVER! Score: ${score}. SPACE = new game`;
saveAndShow();
}
function saveAndShow() {
if (best < 0 || score > best) {
best = score;
localStorage.setItem('cocoman_best', best);
document.getElementById('bestEl').textContent = best;
}
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function startGame() {
score = 0; lives = 3; gameState = 'play';
document.getElementById('scoreEl').textContent = '0';
document.getElementById('livesEl').textContent = '3';
document.getElementById('msg').textContent = '';
document.getElementById('scoreSubmit').style.display = 'none';
initLevel();
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(tick, 155);
}
document.addEventListener('keydown', e => {
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (e.code === 'Space') { e.preventDefault(); startGame(); return; }
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') startGame(); nextDir = map[e.code]; }
});
function dpadInput(dx, dy) { if (gameState === 'idle') startGame(); nextDir = [dx, dy]; }
document.getElementById('btn-up').addEventListener('click', () => dpadInput(0, -1));
document.getElementById('btn-down').addEventListener('click', () => dpadInput(0, 1));
document.getElementById('btn-left').addEventListener('click', () => dpadInput(-1, 0));
document.getElementById('btn-right').addEventListener('click', () => dpadInput(1, 0));
if (best >= 0) document.getElementById('bestEl').textContent = best;
initLevel();
let animFrame = 0;
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (MAP[r][c] === '#') {
ctx.fillStyle = '#00c';
ctx.fillRect(c * CELL, r * CELL, CELL, CELL);
ctx.strokeStyle = '#44f';
ctx.strokeRect(c * CELL + 1, r * CELL + 1, CELL - 2, CELL - 2);
}
}
}
ctx.fillStyle = '#fff';
for (const [dc, dr] of dots) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 2.5, 0, Math.PI * 2); ctx.fill();
}
if (Math.floor(animFrame / 15) % 2) {
ctx.fillStyle = '#FFA500';
for (const [dc, dr] of powers) {
ctx.beginPath(); ctx.arc(dc * CELL + CELL/2, dr * CELL + CELL/2, 5, 0, Math.PI * 2); ctx.fill();
}
}
for (const g of ghosts) {
ctx.fillStyle = frightTimer > 0 ? '#006' : g.color;
const gx = g.x * CELL + CELL/2, gy = g.y * CELL + CELL/2;
ctx.beginPath();
ctx.arc(gx, gy - 2, CELL/2 - 2, Math.PI, 0);
ctx.lineTo(gx + CELL/2 - 2, gy + CELL/2 - 2);
for (let w = 0; w < 4; w++) {
ctx.lineTo(gx + CELL/2 - 2 - w * (CELL - 4) / 4, gy + CELL/2 - 2 - (w % 2 ? 3 : 0));
}
ctx.lineTo(gx - CELL/2 + 2, gy + CELL/2 - 2); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 3, 0, Math.PI * 2); ctx.fill();
if (frightTimer <= 0) {
ctx.fillStyle = '#00c';
ctx.beginPath(); ctx.arc(gx - 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(gx + 3, gy - 3, 1.5, 0, Math.PI * 2); ctx.fill();
}
}
const mouth = Math.abs(Math.sin(animFrame * 0.15)) * 0.4;
const pa = player.dir[0] !== 0 ? Math.atan2(player.dir[1], player.dir[0]) : -Math.PI / 2 * Math.sign(player.dir[1] || 1);
ctx.fillStyle = '#ff0';
ctx.beginPath();
ctx.moveTo(player.x * CELL + CELL/2, player.y * CELL + CELL/2);
ctx.arc(player.x * CELL + CELL/2, player.y * CELL + CELL/2, CELL/2 - 1, pa + mouth, pa + Math.PI * 2 - mouth);
ctx.closePath(); ctx.fill();
animFrame++;
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#000"/>
<g fill="#00f" stroke="#00f" stroke-width="1">
<rect x="5" y="5" width="110" height="8"/>
<rect x="5" y="67" width="110" height="8"/>
<rect x="5" y="5" width="8" height="70"/>
<rect x="107" y="5" width="8" height="70"/>
<rect x="30" y="5" width="8" height="35"/>
<rect x="60" y="5" width="8" height="35"/>
<rect x="90" y="5" width="8" height="35"/>
<rect x="30" y="45" width="8" height="30"/>
<rect x="60" y="45" width="8" height="30"/>
<rect x="90" y="45" width="8" height="30"/>
</g>
<circle cx="20" cy="42" r="8" fill="#ff0"/>
<path d="M20,42 L28,38 L28,46 Z" fill="#000"/>
<circle cx="55" cy="25" r="4" fill="#f00" opacity="0.9"/>
<circle cx="80" cy="55" r="4" fill="#f88" opacity="0.9"/>
<circle cx="19" cy="25" r="2" fill="#fff"/>
<circle cx="45" cy="55" r="2" fill="#fff"/>
<circle cx="75" cy="35" r="2" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

View file

@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ECOINFLOW</title>
<style>
:root {
--pub: #FFA500;
--hab: #8BC34A;
--val: #eeeeee;
--shop: #4CAF50;
--acum: #FF5252;
--check: #FFFF55;
--cbdc: #607D8B;
--bg: #000;
--fg: #eee;
--cell: 64px;
--gap: 5px;
--pad: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100vh; overflow: hidden; }
body {
background: var(--bg); color: var(--fg);
font-family: 'Courier New', ui-monospace, monospace;
height: 100vh; display: flex; flex-direction: column;
align-items: center; user-select: none; -webkit-user-select: none;
}
#topbar { width:100%; padding:8px 16px; display:flex; align-items:center; gap:16px; background:#111; border-bottom:1px solid #333; }
#topbar a { color:#FFA500; text-decoration:none; font-size:14px; }
#topbar a:hover { text-decoration:underline; }
#ui { display:flex; gap:16px; padding:8px; font-size:14px; color:#FFA500; flex-wrap:wrap; justify-content:center; }
#ui b { font-size:15px; }
#hudTurn { transition:color 0.3s; }
#boardWrap { position:relative; flex:1; min-height:0; margin:0; display:flex; align-items:center; justify-content:center; width:100%; overflow:hidden; }
#board { display:grid; gap:var(--gap); background:#222; padding:var(--pad); border:1px solid #3a3a3a; position:relative; }
.cell { width:var(--cell); height:var(--cell); background:#2c2c2c; border:1px solid #444; display:flex; align-items:center; justify-content:center; cursor:pointer; position:relative; transition:background 0.15s; }
.cell.empty:hover { background:#3a3a3a; }
.cell.drawable { background:#4a3800; border-color:#9a7200; }
.cell.drawable.empty:hover { background:#5e4700; }
.cell.in-path { background:#524000; }
.cell.cursor { outline:2px solid var(--pub); outline-offset:-2px; z-index:3; }
.node-label { font-size:40px; font-weight:bold; color:#fff; pointer-events:none; line-height:1; text-align:center; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.45); padding:2px 7px; border-radius:3px; }
.node.validator .node-label,.node.accumulator .node-label { font-size:22px; padding:1px 4px; }
.node { width:82%; height:82%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; font-weight:bold; font-size:11px; color:#000; position:relative; transition:transform 0.2s,opacity 0.2s; }
.node-icon { display:flex; align-items:center; justify-content:center; pointer-events:none; line-height:1; font-size:34px; }
.node.validator .node-icon { font-size:22px; }
.node.inactive { opacity:0.42; }
.node.active { animation:pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100%{transform:scale(1);} 50%{transform:scale(1.07);} }
.node.danger { animation:danger 0.42s ease-in-out infinite !important; opacity:1 !important; }
@keyframes danger { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#c83030;box-shadow:0 0 0 5px rgba(255,82,82,.9),0 0 16px rgba(255,82,82,.5);} }
.node.acum-danger { animation:acumDanger 0.44s ease-in-out infinite !important; opacity:1 !important; }
@keyframes acumDanger { 0%,100%{filter:drop-shadow(0 0 3px rgba(255,82,82,.45));} 50%{filter:drop-shadow(0 0 12px rgba(255,17,68,1)) drop-shadow(0 0 4px rgba(255,82,82,.9));} }
.node.cbdc-threat { animation:cbdcThreat 0.55s ease-in-out infinite !important; opacity:1 !important; }
@keyframes cbdcThreat { 0%,100%{background:var(--hab);box-shadow:0 0 0 2px var(--hab);} 50%{background:#4a6572;box-shadow:0 0 0 5px rgba(96,125,139,.85),0 0 14px rgba(96,125,139,.5);} }
.node.cbdc-alarm { animation:cbdcAlarm 0.7s ease-in-out infinite; }
@keyframes cbdcAlarm { 0%,100%{box-shadow:0 0 0 2px var(--cbdc);} 50%{box-shadow:0 0 0 6px rgba(96,125,139,.8),0 0 14px rgba(96,125,139,.5);} }
.node.pub { background:var(--pub); border-radius:3px; box-shadow:0 0 0 2px var(--pub),0 0 10px rgba(255,165,0,.18); }
.node.pub::before { content:''; position:absolute; width:2px; height:10px; background:var(--pub); top:-10px; left:50%; transform:translateX(-50%); }
.node.pub::after { content:''; position:absolute; width:6px; height:6px; background:var(--pub); border-radius:50%; top:-16px; left:50%; transform:translateX(-50%); }
.node.pub.reduced { outline:2px dashed #c47800; outline-offset:2px; }
.node.inhabitant { background:var(--hab); border-radius:50%; box-shadow:0 0 0 2px var(--hab),0 0 10px rgba(139,195,74,.18); }
.node.inhabitant.cbdc-controlled { background:#455a64; border-radius:50%; box-shadow:0 0 0 2px #455a64; }
.node.checkpoint { background:transparent; border-radius:3px; border:3px solid var(--check); box-shadow:0 0 6px rgba(255,215,0,.35); opacity:1 !important; }
.node.checkpoint.active { background:transparent !important; border:3px solid var(--check) !important; box-shadow:0 0 0 2px var(--check),0 0 18px rgba(255,229,102,.9) !important; filter:none !important; animation:checkActive 1.8s ease-in-out infinite !important; }
.node.checkpoint .node-label { color:var(--check); text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.9); }
@keyframes checkActive { 0%,100%{transform:scale(1.1);box-shadow:0 0 0 2px var(--check),0 0 12px rgba(255,229,102,.65);} 50%{transform:scale(1.22);box-shadow:0 0 0 5px var(--check),0 0 26px rgba(255,229,102,1);} }
.node.validator { background:rgba(200,218,230,.55); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:68%; height:68%; overflow:hidden; filter:drop-shadow(0 0 5px rgba(200,218,230,.7)); }
.node.validator.active { background:var(--val) !important; filter:drop-shadow(0 0 8px rgba(238,238,238,.9)) !important; }
.node.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); filter:drop-shadow(0 0 4px rgba(76,175,80,.45)); }
.node.accumulator { background:rgba(255,82,82,.18); clip-path:polygon(50% 0,100% 100%,0 100%); overflow:hidden; filter:drop-shadow(0 0 4px rgba(255,82,82,.42)); }
.node.cbdc { background:var(--cbdc); border-radius:3px; color:#cfd8dc; font-size:14px; box-shadow:0 0 0 2px var(--cbdc),0 0 10px rgba(96,125,139,.25); }
#pipes { position:absolute; pointer-events:none; }
#pipes path { stroke:var(--pub); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; opacity:.4; fill:none; }
#pipes path.flowing { opacity:1; stroke-width:6; filter:drop-shadow(0 0 4px rgba(255,165,0,.85)) drop-shadow(0 0 10px rgba(255,165,0,.4)); stroke-dasharray:14 8; animation:pipeDash 1.1s linear infinite; }
#pipes path.clickable { pointer-events:auto; cursor:pointer; }
#pipes path.clickable:hover { stroke:#fff; filter:none; stroke-dasharray:none; animation:none; }
#pipes path.cbdc-line { stroke:var(--cbdc); stroke-width:3; stroke-dasharray:5 4; opacity:.75; pointer-events:none; }
#particleLayer { position:absolute; pointer-events:none; }
.flow-particle { position:absolute; width:7px; height:7px; background:var(--pub); border-radius:50%; box-shadow:0 0 7px var(--pub),0 0 14px rgba(255,165,0,.6),0 0 22px rgba(255,165,0,.3); offset-distance:0%; animation:flowParticle 900ms linear infinite; will-change:offset-distance,opacity; }
@keyframes flowParticle { 0%{offset-distance:0%;opacity:0;} 8%{opacity:1;} 85%{opacity:1;} 100%{offset-distance:100%;opacity:0;} }
@keyframes pipeDash { to { stroke-dashoffset:-22; } }
.pot-float { position:absolute; color:var(--acum); font-size:15px; font-weight:bold; font-family:'Courier New',monospace; letter-spacing:1px; pointer-events:none; text-shadow:0 0 8px rgba(255,82,82,.7); animation:potFloat 1.4s ease-out forwards; z-index:20; white-space:nowrap; }
@keyframes potFloat { 0%{transform:translateY(0) scale(1);opacity:1;} 20%{transform:translateY(-6px) scale(1.15);} 100%{transform:translateY(-48px) scale(.9);opacity:0;} }
.check-pulse { position:absolute; border-radius:50%; border:2px solid var(--check); box-shadow:0 0 10px var(--check); pointer-events:none; animation:checkPulse .8s ease-out forwards; z-index:15; }
@keyframes checkPulse { 0%{transform:translate(-50%,-50%) scale(.3);opacity:1;} 100%{transform:translate(-50%,-50%) scale(2.5);opacity:0;} }
#message { margin:8px 0; min-height:18px; font-size:12px; color:#888; text-align:center; max-width:500px; padding:0 16px; line-height:1.5; }
#message.warning { color:var(--pub); background:rgba(255,165,0,.07); border:1px solid rgba(255,165,0,.2); padding:6px 16px; border-radius:2px; }
#message.alert { color:var(--acum); background:rgba(255,82,82,.07); border:1px solid rgba(255,82,82,.2); padding:6px 16px; border-radius:2px; }
#legend { display:flex; flex-wrap:wrap; justify-content:center; gap:10px 16px; margin:4px 0; padding:8px 16px; max-width:560px; font-size:11px; color:#888; }
#legend .item { display:flex; align-items:center; gap:6px; }
#legend .mini { width:16px; height:16px; display:inline-block; flex-shrink:0; }
#legend .mini.pub { background:var(--pub); border-radius:2px; }
#legend .mini.inhabitant { background:var(--hab); border-radius:50%; }
#legend .mini.validator { background:var(--val); clip-path:polygon(50% 0%,100% 50%,50% 100%,0% 50%); width:14px; height:14px; margin:1px; }
#legend .mini.shop { background:var(--shop); clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%); }
#legend .mini.accumulator { background:var(--acum); clip-path:polygon(50% 0,100% 100%,0 100%); }
#legend .mini.checkpoint { background:transparent; border:2px solid var(--check); border-radius:2px; }
#legend .mini.cbdc { background:var(--cbdc); border-radius:2px; }
#controls { color:#888; font-size:13px; text-align:center; margin-bottom:8px; display:flex; gap:10px; flex-wrap:wrap; justify-content:center; align-items:center; padding:0 16px; }
#btnAdvanceTurn { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:8px 18px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
#btnAdvanceTurn:hover { background:var(--pub); color:#000; }
button { background:#0a0a0a; color:var(--fg); border:1px solid var(--pub); padding:10px 16px; font-family:inherit; font-size:12px; cursor:pointer; text-transform:uppercase; letter-spacing:1px; }
button:hover { background:var(--pub); color:#000; }
button.primary { background:var(--pub); color:#000; font-weight:bold; }
button.primary:hover { background:#ffb733; }
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.97); display:flex; align-items:center; justify-content:center; z-index:100; padding:20px; }
.overlay.hidden { display:none; }
.overlay .panel { max-width:480px; border:1px solid var(--pub); padding:24px 28px; background:#050505; }
.overlay h2 { color:var(--pub); margin-bottom:14px; font-size:16px; letter-spacing:1px; }
.overlay p { margin:10px 0; line-height:1.55; font-size:13px; color:#bbb; }
.overlay .hint { color:#bbb; font-size:12px; border-left:2px solid #444; padding-left:10px; margin-top:16px; }
.info-panel { max-width:560px !important; max-height:85vh; overflow-y:auto; }
.info-panel h3 { color:var(--pub); font-size:13px; letter-spacing:1px; margin-top:18px; margin-bottom:6px; text-transform:uppercase; }
.info-panel h3:first-of-type { margin-top:4px; }
.info-panel ul { list-style:none; padding-left:0; margin:8px 0; }
.info-panel ul li { font-size:13px; color:#bbb; padding:4px 0 4px 14px; position:relative; line-height:1.5; }
.info-panel ul li::before { content:'>'; color:var(--pub); position:absolute; left:0; }
.info-panel .stat-line { font-family:inherit; color:#888; font-size:12px; margin:6px 0 12px; }
.info-panel .stat-line b { color:var(--pub); }
.overlay .buttons { margin-top:20px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap; }
#scoreSubmit { margin-top:14px; text-align:center; }
#scoreSubmit button { border-color:var(--check); color:var(--check); background:#1a3a1a; padding:6px 16px; font-size:14px; }
#scoreSubmit button:hover { background:var(--check); color:#000; }
#winBreakdown { font-size:11px; color:#555; line-height:1.9; margin:4px 0 10px; border-left:2px solid #222; padding-left:10px; }
#winBreakdown .positive { color:#6a9a3a; }
#winBreakdown .negative { color:var(--acum); }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">ECOINFLOW</span>
</div>
<div id="ui">
<span>LEVEL: <b id="hudLevel">1</b>/<b id="hudLevelTotal">8</b></span>
<span>TURN: <b id="hudTurn">0</b></span>
<span>PAR: <b id="hudPar">2</b></span>
<span>SCORE: <b id="hudScore">0</b></span>
<span>BEST: <b id="topBest">-</b></span>
</div>
<div id="boardWrap">
<div id="board"></div>
<svg id="pipes"></svg>
<div id="particleLayer"></div>
</div>
<div id="message"></div>
<div id="legend"></div>
<div id="controls">
<button id="btnAdvanceTurn" onclick="tick()">ADVANCE TURN</button>
<span>ENTER &#8212; advance &nbsp;|&nbsp; Backspace &#8212; undo &nbsp;|&nbsp; I &#8212; info</span>
</div>
<div id="overlayIntro" class="overlay">
<div class="panel">
<h2 id="introTitle">LEVEL 1 &#8212; Your first PUB</h2>
<p id="introText"></p>
<p style="margin-top:10px;font-size:12px;line-height:2.2">
<a href="https://wiki.solarnethub.com/ecoin/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">ECOin &#8599;</a>
<a href="https://wiki.solarnethub.com/socialnet/overview" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none;margin-right:14px">Oasis &#8599;</a>
<a href="https://wiki.solarnethub.com/" target="_blank" rel="noopener" style="color:var(--pub);text-decoration:none">SolarNET.HuB &#8599;</a>
</p>
<p class="hint">Click a node to start a pipe, then click adjacent cells to extend it, and finish on another node. Click an existing pipe to remove it. Press <b>ENTER</b> (or ADVANCE TURN button) to process one flow cycle.</p>
<div class="buttons"><button class="primary" onclick="closeIntro()">START</button></div>
</div>
</div>
<div id="overlayWin" class="overlay hidden">
<div class="panel">
<h2>&#10003; LEVEL COMPLETE</h2>
<p id="winText"></p>
<p>Level score: <b id="winLevelPoints" style="color:var(--pub);font-size:18px">0</b></p>
<p>Total score: <b id="winTotalPoints" style="color:var(--check);font-size:18px">0</b></p>
<div id="winBreakdown"></div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="ecoinflow">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<div class="buttons">
<button onclick="restartLevel()">RETRY</button>
<button id="btnNextLevel" class="primary" onclick="goNextLevel()">NEXT LEVEL</button>
</div>
</div>
</div>
<div id="overlayLoss" class="overlay hidden">
<div class="panel">
<h2 style="color:var(--acum)">&#10007; NETWORK DOWN</h2>
<p id="lossText"></p>
<div class="buttons"><button class="primary" onclick="restartLevel()">RETRY</button></div>
</div>
</div>
<div id="overlayInfo" class="overlay hidden">
<div class="panel info-panel">
<h2>INFORMATION</h2>
<div id="infoBody"></div>
<div class="buttons"><button class="primary" onclick="closeInfo()">CLOSE</button></div>
</div>
</div>
<script>
var ecoflowBest = parseInt(localStorage.getItem('ecoinflow_best') || '0');
function updateBest(score) {
if (score > ecoflowBest) { ecoflowBest = score; localStorage.setItem('ecoinflow_best', ecoflowBest); }
document.getElementById('topBest').textContent = ecoflowBest > 0 ? String(ecoflowBest) : '-';
}
updateBest(0);
var LEVELS = [
{ id:1, size:3, par:2,
grid:[['empty','inhabitant','empty'],['pub','empty','inhabitant'],['empty','empty','checkpoint']],
concept:'Your first PUB',
introText:'A PUB is a public server in the Oasis network. It generates ECOin and distributes it to those connected. Your task: connect the PUB to the inhabitants and reach the checkpoint.',
winText:'Network active. Inhabitants receive their flow. This is how a PUB works: it does not accumulate, it distributes.' },
{ id:2, size:4, par:4,
grid:[['inhabitant','empty','inhabitant','empty'],['empty','pub','empty','pub'],['inhabitant','empty','inhabitant','empty'],['empty','empty','checkpoint','empty']],
concept:'Universal Basic Income',
introText:'UBI is the flow that reaches every inhabitant of Oasis, regardless of what they do. It is not charity: it is the network recognizing that your time has value. Each PUB emits UBI to connected inhabitants. None can be left without it.',
winText:'All inhabitants receive UBI. The network is fair when no one is disconnected.' },
{ id:3, size:4, par:5,
grid:[['pub','empty','empty','inhabitant'],['empty','validator','empty','empty'],['inhabitant','empty','validator','empty'],['empty','empty','inhabitant','checkpoint']],
concept:'Validators',
introText:'The ECOin network needs validators: nodes that confirm transactions are legitimate. A validator must accumulate minimum flow before it can pass ECOin through. Plan the connection order: charge validators first, then extend the network through them.',
winText:'Validators active. The blockchain advances. Without validators, there is no network.' },
{ id:4, size:4, par:6,
grid:[['pub','empty','inhabitant','empty'],['inhabitant','accumulator','empty','shop'],['empty','empty','shop','empty'],['inhabitant','empty','pub','checkpoint']],
concept:'Proof of Transaction',
introText:'PoT discourages accumulating ECOin without using it. If flow reaches an accumulator and does not exit, the network penalizes it. You can route around it or connect it to a Shop so the flow keeps circulating. ECOin that does not circulate serves no one.',
winText:'Clean flow. This is how the network maintains its economic health.',
winTextPoT:'PoT triggered. Penalty applied. Next time, keep the flow moving.' },
{ id:5, size:5, par:8,
grid:[['pub','empty','inhabitant','empty','inhabitant'],['empty','validator','empty','shop','empty'],['inhabitant','empty','accumulator','empty','validator'],['empty','shop','empty','inhabitant','empty'],['pub','empty','empty','empty','checkpoint']],
concept:'The Checkpoint',
introText:'The ECOin chain advances through checkpoints: milestones confirming that a number of blocks have been mined and validated. This level integrates all elements seen so far. Manage flow carefully: validators block until loaded, the accumulator penalizes if full. Plan before advancing the turn.',
winText:'Checkpoint reached. The network advances. Every mined block is a step toward digital sovereignty.',
winTextPoT:'Checkpoint reached, but PoT triggered. Penalty applied.' },
{ id:6, size:5, par:10,
grid:[[{type:'pub',capacity:2},'empty','inhabitant','empty','pub'],['inhabitant','validator','empty','shop','inhabitant'],['empty','empty','accumulator','checkpoint','validator'],['pub','shop','empty','inhabitant','empty'],['inhabitant','validator','shop','accumulator','checkpoint']],
concept:'Network Under Pressure',
introText:'The real network operates with limited resources. One PUB is running at reduced capacity (marked with a dashed border). You cannot always feed all nodes at once: learn to prioritize. Inhabitants first, then validators, shops if there is surplus. Two checkpoints to activate.',
winText:'Network stabilized under pressure. Digital sovereignty requires conscious management of real resources.',
winTextPoT:'Network stabilized, but PoT penalized. Optimize the accumulator flow.' },
{ id:7, size:5, par:12,
grid:[['pub','inhabitant','empty','inhabitant','pub'],['inhabitant','cbdc','inhabitant','empty','empty'],['empty','inhabitant','empty','validator','shop'],['pub','empty','shop','empty','inhabitant'],['empty','inhabitant','empty','validator','checkpoint']],
concept:'The Digital Euro',
introText:'The Digital Euro is being implemented. Unlike ECOin, a CBDC (Central Bank Digital Currency) is not sovereignty: it is surveillance. The CBDC node tries to capture adjacent inhabitants not connected to a PUB every 2 turns. Connect your PUBs to vulnerable inhabitants first. Speed matters.',
winText:'Sovereign network. Inhabitants are connected to community PUBs, not surveillance infrastructure. This is what is at stake.',
winTextCBDC:'Some inhabitants were captured by CBDC before you could connect them. The network survived, but not all are free.' },
{ id:8, size:6, par:14,
grid:[['pub','empty','inhabitant','empty','inhabitant','pub'],['empty','validator','empty','shop','empty','inhabitant'],['inhabitant','empty','accumulator','empty','validator','empty'],['empty','shop','empty','inhabitant','empty','shop'],['pub','empty','validator','empty','pub','empty'],['inhabitant','empty','cbdc','inhabitant','empty','checkpoint']],
concept:'Complete Oasis',
introText:'This is Oasis. A decentralized network where ECOin flows from PUBs to inhabitants as universal basic income, validators maintain blockchain integrity, shops sustain the internal economy, and checkpoints consolidate network history. CBDC tries to expand from the edge. There is no central bank. Only the network. Complete the picture.',
winText:'Oasis active. You have built a sovereign network. Now you know how it works. Now you can be part of it.' }
];
function createCell(type) {
return { type:type, active:type==='pub', flowReceivedThisTurn:0, turnosSinFlujo:0, acumulado:0, validadorCargado:0, contadorActivo:0, bloqueado:false, capacity:null, cbdcControlled:false };
}
var state = { levelIndex:0, size:0, board:[], paths:[], drawing:null, turn:0, score:0, potActivations:0, cbdcCaptured:new Set(), pendingEvents:[], status:'intro', cursor:{r:0,c:0} };
function loadLevel(i) {
var lvl = LEVELS[i];
state.levelIndex = i; state.size = lvl.size;
state.board = lvl.grid.map(function(row) { return row.map(function(def) {
if (typeof def === 'string') return createCell(def);
var cell = createCell(def.type);
if (def.capacity !== undefined) cell.capacity = def.capacity;
return cell;
}); });
state.paths = []; state.drawing = null; state.turn = 0;
if (i === 0) state.score = 0;
state.potActivations = 0; state.cbdcCaptured = new Set(); state.pendingEvents = [];
state.status = 'playing'; state.cursor = {r:0,c:0};
document.getElementById('hudLevel').textContent = lvl.id;
document.getElementById('hudLevelTotal').textContent = LEVELS.length;
document.getElementById('hudPar').textContent = lvl.par;
document.getElementById('introTitle').textContent = 'LEVEL ' + lvl.id + ' \u2014 ' + lvl.concept;
document.getElementById('introText').textContent = lvl.introText;
renderLegend(); showMessage(''); resizeBoard(); render();
}
var LEGEND_LABELS = { pub:'PUB', inhabitant:'Inhabitant', validator:'Validator', shop:'Shop', accumulator:'Accumulator', checkpoint:'Checkpoint', cbdc:'CBDC' };
var LEGEND_ORDER = ['pub','inhabitant','validator','shop','accumulator','checkpoint','cbdc'];
function renderLegend() {
var present = new Set();
for (var r=0;r<state.size;r++) for (var c=0;c<state.size;c++) { var t=state.board[r][c].type; if(t!=='empty') present.add(t); }
var el = document.getElementById('legend');
el.innerHTML = '';
LEGEND_ORDER.forEach(function(type) {
if (!present.has(type)) return;
var item = document.createElement('div'); item.className = 'item';
var mini = document.createElement('span'); mini.className = 'mini '+type;
var label = document.createElement('span'); label.textContent = LEGEND_LABELS[type];
item.appendChild(mini); item.appendChild(label); el.appendChild(item);
});
}
function getCell(r,c) { if(r<0||c<0||r>=state.size||c>=state.size) return null; return state.board[r][c]; }
function areAdjacent(a,b) { return Math.abs(a.r-b.r)+Math.abs(a.c-b.c)===1; }
function cellInOtherPath(r,c,exc) {
for(var i=0;i<state.paths.length;i++) { if(i===exc) continue; var p=state.paths[i]; for(var k=1;k<p.length-1;k++) if(p[k].r===r&&p[k].c===c) return true; }
return false;
}
function countConnections(r,c) {
return state.paths.filter(function(p){var a=p[0],b=p[p.length-1]; return(a.r===r&&a.c===c)||(b.r===r&&b.c===c);}).length;
}
function getConnectedNodes(r,c) {
var result=[];
state.paths.forEach(function(path){ var a=path[0],b=path[path.length-1]; if(a.r===r&&a.c===c) result.push(b); else if(b.r===r&&b.c===c) result.push(a); });
return result;
}
function isConnectedToPUB(sr,sc) {
var visited=new Set(), queue=[sr+','+sc]; visited.add(sr+','+sc);
while(queue.length>0) {
var key=queue.shift(), parts=key.split(','), kr=+parts[0], kc=+parts[1], cell=state.board[kr][kc];
if(cell.type==='pub'&&cell.active&&!cell.bloqueado) return true;
getConnectedNodes(kr,kc).forEach(function(n){ var nk=n.r+','+n.c; if(!visited.has(nk)){visited.add(nk);queue.push(nk);} });
}
return false;
}
var PUB_OUTPUT=4;
function computePreviewFlow() {
var flow={};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) flow[r+','+c]=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOut=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOut/outs.length;
outs.forEach(function(n){ previewPropagate(n.r,n.c,share,new Set([r+','+c]),flow); });
}
return flow;
}
function previewPropagate(r,c,amount,visited,flow) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') flow[k]=(flow[k]||0)+amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){ previewPropagate(n.r,n.c,amount,new Set(visited),flow); });
}
function render() { renderBoard(); renderPipes(); renderParticles(); renderHud(); }
function makeEcoinSVG() {
var ns='http://www.w3.org/2000/svg';
var svg=document.createElementNS(ns,'svg');
svg.setAttribute('viewBox','0 0 32 32'); svg.setAttribute('width','68%'); svg.setAttribute('height','68%');
svg.classList.add('node-icon');
var c1=document.createElementNS(ns,'circle'); c1.setAttribute('cx','16'); c1.setAttribute('cy','16'); c1.setAttribute('r','13'); c1.setAttribute('fill','#0d0800'); c1.setAttribute('stroke','#FFA500'); c1.setAttribute('stroke-width','2.5');
var c2=document.createElementNS(ns,'circle'); c2.setAttribute('cx','16'); c2.setAttribute('cy','16'); c2.setAttribute('r','9'); c2.setAttribute('fill','none'); c2.setAttribute('stroke','rgba(255,165,0,0.35)'); c2.setAttribute('stroke-width','1');
var t=document.createElementNS(ns,'text'); t.setAttribute('x','16'); t.setAttribute('y','21'); t.setAttribute('text-anchor','middle'); t.setAttribute('font-size','15'); t.setAttribute('font-weight','bold'); t.setAttribute('fill','#FFA500'); t.setAttribute('font-family','monospace'); t.textContent='E';
svg.appendChild(c1); svg.appendChild(c2); svg.appendChild(t);
return svg;
}
function renderBoard() {
var preview=computePreviewFlow();
var fmtFlow=function(v){return Number.isInteger(v)?String(v):v.toFixed(1);};
var board=document.getElementById('board');
board.innerHTML='';
board.style.gridTemplateColumns='repeat('+state.size+', var(--cell))';
var last=state.drawing?state.drawing.cells[state.drawing.cells.length-1]:null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
(function(r,c){
var cell=state.board[r][c];
var div=document.createElement('div'); div.className='cell';
if(cell.type==='empty') div.classList.add('empty');
if(state.cursor&&state.cursor.r===r&&state.cursor.c===c) div.classList.add('cursor');
if(last&&areAdjacent(last,{r:r,c:c})) {
var alreadyIn=state.drawing.cells.some(function(p){return p.r===r&&p.c===c;});
var blocked=cell.type==='empty'&&cellInOtherPath(r,c);
if(!alreadyIn&&!blocked) div.classList.add('drawable');
}
if(state.drawing&&state.drawing.cells.some(function(p){return p.r===r&&p.c===c;})) div.classList.add('in-path');
if(cell.type!=='empty') {
var node=document.createElement('div'); node.className='node '+cell.type;
if(cell.cbdcControlled){ node.classList.add('cbdc-controlled'); node.classList.add('inactive'); }
else if(cell.active&&!cell.bloqueado) node.classList.add('active');
else node.classList.add('inactive');
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&cell.turnosSinFlujo>=2){ node.classList.remove('inactive'); node.classList.add('danger'); }
if(cell.type==='inhabitant'&&!cell.cbdcControlled&&!node.classList.contains('danger')) {
var adjToCBDC=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; return state.board[p.r][p.c].type==='cbdc'; });
if(adjToCBDC&&!isConnectedToPUB(r,c)){ node.classList.remove('inactive'); node.classList.add('cbdc-threat'); }
}
if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) node.classList.add('acum-danger');
if(cell.type==='cbdc') {
var hasVuln=[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].some(function(p){ if(p.r<0||p.c<0||p.r>=state.size||p.c>=state.size) return false; var adj=state.board[p.r][p.c]; return adj.type==='inhabitant'&&!adj.cbdcControlled&&!isConnectedToPUB(p.r,p.c); });
if(hasVuln){ node.classList.remove('inactive'); node.classList.add('cbdc-alarm'); }
}
if(cell.type==='pub'&&cell.capacity!==null) node.classList.add('reduced');
if(cell.type==='pub'){ node.appendChild(makeEcoinSVG()); }
else if(cell.type==='inhabitant'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83E\uDD65'; node.appendChild(ico); }
else if(cell.type==='validator'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\u2714'; node.appendChild(ico); }
else if(cell.type==='cbdc'){ var ico=document.createElement('span'); ico.className='node-icon'; ico.textContent='\uD83D\uDC80'; node.appendChild(ico); }
if(cell.type==='checkpoint'){ var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Math.min(cell.contadorActivo,3); node.appendChild(lbl); }
if(cell.type==='validator') {
if(!cell.active){ var pct=Math.round(Math.min(100,(cell.validadorCargado/3)*100)); node.style.background='linear-gradient(to top,#c8dde8 '+pct+'%,rgba(200,218,230,.55) '+pct+'%)'; }
var pf=preview[r+','+c]||0; var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(pf); node.appendChild(lbl);
}
if(cell.type==='pub') {
var pubOutput=cell.capacity!==null?cell.capacity:4, conns=countConnections(r,c), share=conns>1?pubOutput/conns:pubOutput;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=Number.isInteger(share)?share:share.toFixed(1); node.appendChild(lbl);
}
if(cell.type==='inhabitant'&&!cell.cbdcControlled) {
var pf=preview[r+','+c]||0, health=3-cell.turnosSinFlujo;
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=health;
lbl.style.color=pf>=1?'#fff':pf>0?'#FFA500':'#FF5252'; node.appendChild(lbl);
}
if(cell.type==='accumulator'&&!cell.bloqueado) {
var acc=cell.acumulado, pct=Math.round(Math.min(100,(acc/5)*100));
var fillColor=acc>=4.5?'#FF1744':acc>=3?'#FF5252':acc>=1.5?'#E53935':'#B71C1C';
node.style.background='linear-gradient(to top,'+fillColor+' '+pct+'%,rgba(255,82,82,.18) '+pct+'%)';
var pf=preview[r+','+c]||0, projected=Math.min(acc+pf,5);
var lbl=document.createElement('span'); lbl.className='node-label'; lbl.textContent=fmtFlow(projected)+'/5'; node.appendChild(lbl);
}
div.appendChild(node);
}
div.setAttribute('role','button');
div.setAttribute('aria-label',cell.type==='empty'?'Empty cell':LEGEND_LABELS[cell.type]||cell.type);
div.addEventListener('click',function(){onCellClick(r,c);});
board.appendChild(div);
})(r,c);
}
}
function cellCenter(r,c) {
var boardEl=document.getElementById('board'), cellEls=boardEl.querySelectorAll('.cell');
if(cellEls.length>0) {
var idx=r*state.size+c, rect=cellEls[idx]?cellEls[idx].getBoundingClientRect():null, boardRect=boardEl.getBoundingClientRect();
if(rect) return {x:rect.left-boardRect.left+rect.width/2,y:rect.top-boardRect.top+rect.height/2};
}
var pad=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--pad'))||8;
var gap=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--gap'))||4;
var cs=parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell'))||72;
return {x:pad+c*(cs+gap)+cs/2,y:pad+r*(cs+gap)+cs/2};
}
function boardOffset() {
var boardEl=document.getElementById('board'), wrapEl=document.getElementById('boardWrap');
if(!boardEl||!wrapEl) return {x:0,y:0};
var br=boardEl.getBoundingClientRect(), wr=wrapEl.getBoundingClientRect();
return {x:br.left-wr.left, y:br.top-wr.top};
}
function renderPipes() {
var svg=document.getElementById('pipes'), boardEl=document.getElementById('board');
var off=boardOffset(), w=boardEl.offsetWidth, h=boardEl.offsetHeight;
svg.setAttribute('width',w); svg.setAttribute('height',h);
svg.style.width=w+'px'; svg.style.height=h+'px';
svg.style.left=off.x+'px'; svg.style.top=off.y+'px';
svg.innerHTML='';
var flowingIdx=computeFlowingPaths();
var cellEl=document.querySelector('#board .cell'), nodeR=cellEl?cellEl.offsetWidth*0.40:26;
function trimPt(from,toward,r){ var dx=toward.x-from.x,dy=toward.y-from.y,len=Math.sqrt(dx*dx+dy*dy); return len>0?{x:from.x+dx/len*r,y:from.y+dy/len*r}:from; }
function makePath(pts){ var d='M '+pts[0].x.toFixed(1)+' '+pts[0].y.toFixed(1); for(var i=1;i<pts.length;i++) d+=' L '+pts[i].x.toFixed(1)+' '+pts[i].y.toFixed(1); return d; }
state.paths.forEach(function(path,idx) {
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>=2){ pts=pts.slice(); pts[0]=trimPt(pts[0],pts[1],nodeR); pts[pts.length-1]=trimPt(pts[pts.length-1],pts[pts.length-2],nodeR); }
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.classList.add('clickable');
if(flowingIdx.has(idx)) el.classList.add('flowing');
el.addEventListener('click',function(e){e.stopPropagation();removePath(idx);});
svg.appendChild(el);
});
state.cbdcCaptured.forEach(function(key){
var parts=key.split(','), cr=+parts[0], cc=+parts[1];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
var from=cellCenter(r,c), to=cellCenter(cr,cc);
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d','M '+from.x.toFixed(1)+' '+from.y.toFixed(1)+' L '+to.x.toFixed(1)+' '+to.y.toFixed(1)); el.classList.add('cbdc-line'); svg.appendChild(el);
}
});
if(state.drawing&&state.drawing.cells.length>0) {
var pts=state.drawing.cells.map(function(p){return cellCenter(p.r,p.c);});
if(pts.length>1){
var el=document.createElementNS('http://www.w3.org/2000/svg','path');
el.setAttribute('d',makePath(pts)); el.setAttribute('stroke-dasharray','8 5'); el.setAttribute('opacity','0.75'); el.setAttribute('stroke-width','4'); svg.appendChild(el);
}
var start=pts[0], circle=document.createElementNS('http://www.w3.org/2000/svg','circle');
circle.setAttribute('cx',start.x.toFixed(1)); circle.setAttribute('cy',start.y.toFixed(1)); circle.setAttribute('r',nodeR);
circle.setAttribute('fill','rgba(255,165,0,.15)'); circle.setAttribute('stroke','#FFA500'); circle.setAttribute('stroke-width','2'); svg.appendChild(circle);
}
}
function computeFlowingPaths() {
var flowing=new Set();
state.paths.forEach(function(p,i){ var a=state.board[p[0].r][p[0].c], b=state.board[p[p.length-1].r][p[p.length-1].c]; var aPub=a.type==='pub'&&a.active&&!a.bloqueado, bPub=b.type==='pub'&&b.active&&!b.bloqueado; if(aPub&&!b.bloqueado) flowing.add(i); if(bPub&&!a.bloqueado) flowing.add(i); });
return flowing;
}
function renderParticles() {
var layer=document.getElementById('particleLayer'); if(!layer) return;
var boardEl=document.getElementById('board'), off=boardOffset();
layer.style.left=off.x+'px'; layer.style.top=off.y+'px';
layer.style.width=boardEl.offsetWidth+'px'; layer.style.height=boardEl.offsetHeight+'px';
layer.querySelectorAll('.flow-particle').forEach(function(el){el.remove();});
if(state.status!=='playing'&&state.status!=='won') return;
var flowingIdx=computeFlowingPaths(), DURATION=900, N=4;
flowingIdx.forEach(function(idx){
var path=state.paths[idx]; if(!path||path.length<2) return;
var pts=path.map(function(p){return cellCenter(p.r,p.c);});
var cellA=state.board[path[0].r][path[0].c], cellB=state.board[path[path.length-1].r][path[path.length-1].c];
if(cellB.type==='pub'&&cellA.type!=='pub') pts=pts.slice().reverse();
var d='M '+pts.map(function(p){return p.x.toFixed(1)+' '+p.y.toFixed(1);}).join(' L ');
for(var k=0;k<N;k++){
var particle=document.createElement('div'); particle.className='flow-particle';
particle.style.offsetPath='path("'+d+'")'; particle.style.animationDuration=DURATION+'ms'; particle.style.animationDelay=(-k*DURATION/N)+'ms';
layer.appendChild(particle);
}
});
}
function fireEvents() {
var layer=document.getElementById('particleLayer'); if(!layer||state.pendingEvents.length===0) return;
state.pendingEvents.forEach(function(evt){
var center=cellCenter(evt.r,evt.c);
if(evt.type==='pot'){ var txt=document.createElement('div'); txt.className='pot-float'; txt.textContent='\u221250'; txt.style.left=(center.x-16)+'px'; txt.style.top=(center.y-10)+'px'; layer.appendChild(txt); txt.addEventListener('animationend',function(){txt.remove();}); }
if(evt.type==='checkpoint'){ var ring=document.createElement('div'); ring.className='check-pulse'; ring.style.left=center.x+'px'; ring.style.top=center.y+'px'; ring.style.width='30px'; ring.style.height='30px'; layer.appendChild(ring); ring.addEventListener('animationend',function(){ring.remove();}); }
});
}
function renderHud() {
document.getElementById('hudScore').textContent=state.score;
var lvl=LEVELS[state.levelIndex], turnEl=document.getElementById('hudTurn');
turnEl.textContent=state.turn;
turnEl.style.color=state.turn>lvl.par?'#FF5252':state.turn===lvl.par?'#FFA500':'var(--pub)';
}
function showMessage(text,type) {
var el=document.getElementById('message'); el.textContent=text||'';
el.className=type==='alert'?'alert':type==='warning'?'warning':'';
}
function buildTurnStatus() {
var parts=[];
var checks=[];
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint') checks.push(cell.active?'\u2713':cell.contadorActivo+'/3'); }
if(checks.length>0) parts.push('Checkpoint: '+checks.join(' '));
var habRisk1=0,habRisk2=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='inhabitant'&&!cell.cbdcControlled){ if(cell.turnosSinFlujo===1) habRisk1++; else if(cell.turnosSinFlujo>=2) habRisk2++; } }
if(habRisk2>0) parts.push('\u26a0 '+habRisk2+' inhabitant(s) in danger');
else if(habRisk1>0) parts.push(habRisk1+' inhabitant(s) without flow');
var acumP=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='accumulator'&&!cell.bloqueado&&cell.acumulado>=4) acumP++; }
if(acumP>0) parts.push('\u26a0 Accumulator almost full');
return parts.join(' \u00b7 ');
}
function onCellClick(r,c) {
if(state.status!=='playing') return;
var cell=state.board[r][c];
if(!state.drawing) {
if(cell.type==='empty'){ showMessage('Start drawing by clicking on a node.'); return; }
if(cell.type==='inhabitant'){ showMessage('Inhabitants can only receive connections.'); return; }
state.drawing={cells:[{r:r,c:c}]};
showMessage('Drawing from '+(LEGEND_LABELS[cell.type]||cell.type).toUpperCase()+'. Click adjacent cells and finish on another node.');
render(); return;
}
var cells=state.drawing.cells, last=cells[cells.length-1];
if(cells.length===1&&last.r===r&&last.c===c){ cancelDrawing(); return; }
if(!areAdjacent(last,{r:r,c:c})){ showMessage('Can only extend to adjacent cells.'); return; }
if(cells.some(function(p){return p.r===r&&p.c===c;})){ showMessage('That cell is already in the path.'); return; }
if(cell.type==='empty'&&cellInOtherPath(r,c)){ showMessage('That cell already has a pipe.'); return; }
cells.push({r:r,c:c});
if(cell.type!=='empty'){ finalizeDrawing(); return; }
render();
}
function finalizeDrawing() {
var cells=state.drawing.cells, first=state.board[cells[0].r][cells[0].c], last=state.board[cells[cells.length-1].r][cells[cells.length-1].c];
if(first.type==='empty'||last.type==='empty'||cells.length<2){ cancelDrawing(); return; }
var a=cells[0], b=cells[cells.length-1];
if(countConnections(a.r,a.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
if(last.type==='inhabitant'&&countConnections(b.r,b.c)>=1){ showMessage('This inhabitant is already connected.'); cancelDrawing(); return; }
if(countConnections(b.r,b.c)>=4){ showMessage('That node already has 4 connections (maximum).'); cancelDrawing(); return; }
var dup=state.paths.some(function(p){ var x=p[0],y=p[p.length-1]; return(x.r===a.r&&x.c===a.c&&y.r===b.r&&y.c===b.c)||(x.r===b.r&&x.c===b.c&&y.r===a.r&&y.c===a.c); });
if(dup){ showMessage('A pipe between those nodes already exists.'); cancelDrawing(); return; }
state.paths.push(cells); state.drawing=null; state.score+=2; showMessage('Pipe created. (+2)'); render();
}
function cancelDrawing(){ state.drawing=null; showMessage(''); render(); }
function removePath(idx){ if(state.status!=='playing') return; state.paths.splice(idx,1); showMessage('Pipe removed.'); render(); }
function propagateFrom(r,c,amount,visited) {
var cell=state.board[r][c], k=r+','+c;
if(visited.has(k)) return; visited.add(k);
if(cell.type!=='pub') cell.flowReceivedThisTurn+=amount;
if(cell.type!=='validator'||!cell.active||cell.bloqueado) return;
getConnectedNodes(r,c).filter(function(n){return!visited.has(n.r+','+n.c);}).forEach(function(n){propagateFrom(n.r,n.c,amount,new Set(visited));});
}
function tick() {
if(state.status!=='playing') return;
if(state.paths.length===0) showMessage('No pipes: connect nodes before advancing. Inhabitants will lose flow.','warning');
state.turn++;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) state.board[r][c].flowReceivedThisTurn=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c];
if(cell.type!=='pub'||!cell.active||cell.bloqueado) continue;
var outs=getConnectedNodes(r,c); if(!outs.length) continue;
var pubOutput=cell.capacity!==null?cell.capacity:PUB_OUTPUT, share=pubOutput/outs.length;
outs.forEach(function(n){ var v=new Set(); v.add(r+','+c); propagateFrom(n.r,n.c,share,v); });
}
var caidoDesc=null;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
var cell=state.board[r][c], f=cell.flowReceivedThisTurn;
switch(cell.type) {
case 'inhabitant':
if(cell.cbdcControlled) break;
if(f>=1){ cell.active=true; cell.turnosSinFlujo=0; }
else{ cell.active=false; cell.turnosSinFlujo++; if(cell.turnosSinFlujo>=3&&!caidoDesc) caidoDesc='Inhabitant at row '+(r+1)+', col '+(c+1)+' has gone 3 turns without ECOin.'; }
break;
case 'validator': cell.validadorCargado+=f; if(cell.validadorCargado>=3) cell.active=true; break;
case 'shop': cell.active=f>=2; break;
case 'accumulator':
cell.acumulado+=f;
if(cell.acumulado>5&&!cell.bloqueado){ cell.bloqueado=true; cell.active=false; state.potActivations++; state.pendingEvents.push({type:'pot',r:r,c:c}); }
break;
case 'checkpoint':
if(f>0){ cell.contadorActivo++; if(cell.contadorActivo>=3){ cell.active=true; if(cell.contadorActivo===3) state.pendingEvents.push({type:'checkpoint',r:r,c:c}); } }
break;
}
}
if(state.turn%2===0) {
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) {
if(state.board[r][c].type!=='cbdc') continue;
[{r:r-1,c:c},{r:r+1,c:c},{r:r,c:c-1},{r:r,c:c+1}].filter(function(p){return p.r>=0&&p.c>=0&&p.r<state.size&&p.c<state.size;}).forEach(function(p){
var adjCell=state.board[p.r][p.c];
if(adjCell.type!=='inhabitant'||adjCell.cbdcControlled) return;
if(isConnectedToPUB(p.r,p.c)) return;
adjCell.cbdcControlled=true; adjCell.active=false; state.cbdcCaptured.add(p.r+','+p.c);
if(!caidoDesc) caidoDesc='Inhabitant at row '+(p.r+1)+', col '+(p.c+1)+' was captured by CBDC. Connect PUBs first.';
});
}
}
if(caidoDesc){ state.status='lost'; document.getElementById('lossText').textContent=caidoDesc+' The network cannot leave anyone disconnected. Review your connections.'; document.getElementById('overlayLoss').classList.remove('hidden'); render(); return; }
var hasCheckpoint=false, allActive=true;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){ var cell=state.board[r][c]; if(cell.type==='checkpoint'){ hasCheckpoint=true; if(!cell.active) allActive=false; } }
if(hasCheckpoint&&allActive) {
state.status='won';
var lvl=LEVELS[state.levelIndex], base=100, shopsActive=0;
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++) if(state.board[r][c].type==='shop'&&state.board[r][c].active) shopsActive++;
var bonusShops=shopsActive*10, bonusTurns=Math.max(0,lvl.par-state.turn)*5, penalPot=state.potActivations*50, levelScore=base+bonusShops+bonusTurns-penalPot;
state.score+=levelScore;
var winMsg=lvl.winText;
if(state.potActivations>0&&lvl.winTextPoT) winMsg=lvl.winTextPoT;
if(state.cbdcCaptured.size>0&&lvl.winTextCBDC) winMsg=lvl.winTextCBDC;
document.getElementById('winText').textContent=winMsg;
document.getElementById('winLevelPoints').textContent=levelScore;
document.getElementById('winTotalPoints').textContent=state.score;
document.getElementById('scoreInput').value=state.score;
var bd=document.getElementById('winBreakdown'); bd.innerHTML='';
function bdLine(cls,txt){ var s=document.createElement('span'); s.className=cls; s.textContent=txt; bd.appendChild(s); }
bdLine('positive','Base: +'+base);
if(bonusShops>0) bdLine('positive','Active shops ('+shopsActive+'): +'+bonusShops);
if(bonusTurns>0) bdLine('positive','Turns under par ('+(lvl.par-state.turn)+'): +'+bonusTurns);
if(penalPot>0) bdLine('negative','PoT triggered (\u00d7'+state.potActivations+'): \u221250');
var isLastLevel=state.levelIndex+1>=LEVELS.length;
var beatsBest=state.score>ecoflowBest;
updateBest(state.score);
document.getElementById('scoreSubmit').style.display=(isLastLevel&&beatsBest&&state.score>0)?'block':'none';
var nextBtn=document.getElementById('btnNextLevel'); nextBtn.style.display=(state.levelIndex+1<LEVELS.length)?'inline-block':'none';
render(); fireEvents(); state.pendingEvents=[];
setTimeout(function(){document.getElementById('overlayWin').classList.remove('hidden');},1500);
return;
}
var statusMsg=buildTurnStatus();
if(statusMsg){ showMessage(statusMsg,statusMsg.includes('danger')||statusMsg.includes('full')?'alert':null); }
else showMessage('Turn '+state.turn+' \u2014 no issues.');
render(); fireEvents(); state.pendingEvents=[];
}
function restartLevel() {
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
if(state.levelIndex===0) state.score=0;
loadLevel(state.levelIndex);
}
function goNextLevel() {
if(state.levelIndex+1>=LEVELS.length) return;
document.getElementById('overlayWin').classList.add('hidden'); document.getElementById('overlayLoss').classList.add('hidden');
loadLevel(state.levelIndex+1); state.status='intro'; document.getElementById('overlayIntro').classList.remove('hidden');
}
function closeIntro(){ document.getElementById('overlayIntro').classList.add('hidden'); state.status='playing'; }
function closeInfo(){ document.getElementById('overlayInfo').classList.add('hidden'); }
function mk(tag,cls,txt){ var el=document.createElement(tag); if(cls) el.className=cls; if(txt!==undefined) el.textContent=txt; return el; }
function mka(tag,cls,attrs){ var el=document.createElement(tag); if(cls) el.className=cls; if(attrs) Object.keys(attrs).forEach(function(k){el.setAttribute(k,attrs[k]);}); return el; }
function openInfo() {
var lvl=LEVELS[state.levelIndex], body=document.getElementById('infoBody'); body.innerHTML='';
var habOK=0,habRiesgo=0,habCount=0,habCBDC=0,checkpoints=[],valActivos=0,valCargando=[],acumFilling=[],acumBloqueados=0,shopsActive=0,shopsInactive=0,pubsActivos=0;
var fmt=function(n){return(Math.round(n*10)/10).toString();};
for(var r=0;r<state.size;r++) for(var c=0;c<state.size;c++){
var cell=state.board[r][c];
switch(cell.type){
case 'pub': if(cell.active&&!cell.bloqueado) pubsActivos++; break;
case 'inhabitant': habCount++; if(cell.cbdcControlled) habCBDC++; else if(cell.turnosSinFlujo===0) habOK++; else habRiesgo++; break;
case 'checkpoint': checkpoints.push(cell.contadorActivo); break;
case 'validator': if(cell.active) valActivos++; else valCargando.push(cell.validadorCargado); break;
case 'accumulator': if(cell.bloqueado) acumBloqueados++; else acumFilling.push(cell.acumulado); break;
case 'shop': if(cell.active) shopsActive++; else shopsInactive++; break;
}
}
function h3(t){ var el=mk('h3'); el.textContent=t; body.appendChild(el); }
function p(t){ var el=mk('p'); el.textContent=t; body.appendChild(el); }
function ul(items){ var ul=mk('ul'); items.forEach(function(t){ var li=mk('li'); li.textContent=t; ul.appendChild(li); }); body.appendChild(ul); }
h3('Level objective');
p(lvl.introText);
p('Goal: activate all checkpoints (3 turns with flow) without any inhabitant going 3 turns without ECOin.');
h3('Current status');
var stat=mk('div','stat-line');
stat.textContent='Turn '+state.turn+' \u00b7 Par '+lvl.par+' \u00b7 Score '+state.score+' \u00b7 Active PUBs '+pubsActivos+' \u00b7 PoT activations '+state.potActivations;
body.appendChild(stat);
var statusItems=[];
if(habCount>0){ var t='Inhabitants: '+habOK+' with flow'; if(habRiesgo>0) t+=', '+habRiesgo+' without flow (fall at 3 turns)'; if(habCBDC>0) t+=', '+habCBDC+' captured by CBDC'; statusItems.push(t); }
if(checkpoints.length>0) statusItems.push('Checkpoints: '+checkpoints.map(function(p){return p>=3?'active':p+'/3';}).join(' / '));
if(valActivos>0||valCargando.length>0){ var t=(valActivos>0?valActivos+' active':'')+(valCargando.length>0?(valActivos>0?', ':'')+'loading: '+valCargando.map(function(v){return fmt(v)+'/3';}).join(', '):''); statusItems.push('Validators: '+t); }
if(acumFilling.length>0||acumBloqueados>0){ var t=(acumFilling.length>0?'filling: '+acumFilling.map(function(a){return fmt(a)+'/5';}).join(', '):'')+(acumBloqueados>0?(acumFilling.length>0?', ':'')+acumBloqueados+' blocked (PoT)':''); statusItems.push('Accumulators: '+t+' (-50 pts when exceeding 5)'); }
if(shopsActive>0||shopsInactive>0) statusItems.push('Shops: '+shopsActive+' active'+(shopsInactive>0?', '+shopsInactive+' inactive':'')+' (+10 pts each when winning)');
ul(statusItems);
h3('How to play');
ul(['Click a node to start a pipe, click adjacent cells to extend, finish on another node.','Click an existing pipe to remove it.','Press ENTER (or ADVANCE TURN button) to process one flow cycle.','A node can have a maximum of 4 connections. Inhabitants accept only 1.', '+2 score per pipe placed.']);
h3('ECOin rules');
ul(['Each active PUB generates 4 ECOin units/turn (2 if reduced capacity), split equally among connections.','An inhabitant needs at least 1 unit/turn. Three turns without flow = defeat.','A validator blocks flow until it accumulates 3 units. Once active it forwards flow to other connections.','An accumulator absorbs without redistributing. Exceeding 5 units triggers PoT: -50 pts and node blocked.','A shop needs at least 2 units/turn to operate. Active shops when winning: +10 pts each.','A checkpoint activates after receiving flow for 3 consecutive turns. Activating all checkpoints wins the level.','CBDC captures adjacent inhabitants not connected to a PUB every 2 turns = defeat.']);
h3('Scoring');
ul(['+2 per pipe placed','+100 per level completed','+10 per active shop at completion','+5 per turn saved vs. par (only if completed under par)','-50 per accumulator blocked by PoT','Score accumulates across all levels']);
h3('Glossary');
ul(['PUB - Public Server: Oasis network node distributing ECOin to connected inhabitants. Not centralized.','ECOin: Community currency. Its value is anchored to human time. Cannot be issued arbitrarily.','UBI - Universal Basic Income: Flow reaching every inhabitant regardless of activity.','PoT - Proof of Transaction: Discourages accumulating ECOin without using it.','Validator: Confirms transaction legitimacy. Needs minimum flow before it can operate.','Checkpoint: Milestone on the ECOin blockchain. When reached, history is consolidated and immutable.','CBDC - Central Bank Digital Currency: Digital currency by a central bank. Enables tracking every transaction = total financial surveillance.']);
document.getElementById('overlayInfo').classList.remove('hidden');
}
function undoAction() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); }
else if(state.paths.length>0){ state.paths.pop(); showMessage('Pipe undone.'); render(); }
else showMessage('Nothing to undo.');
}
function deletePipeAtCursor() {
if(state.status!=='playing') return;
if(state.drawing){ cancelDrawing(); return; }
var r=state.cursor.r, c=state.cursor.c, before=state.paths.length;
state.paths=state.paths.filter(function(path){return!path.some(function(p){return p.r===r&&p.c===c;});});
if(state.paths.length<before){ showMessage('Pipe removed.'); render(); }
}
document.addEventListener('keydown',function(e){
if(e.code==='Enter'){
e.preventDefault();
if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo();
else if(!document.getElementById('overlayIntro').classList.contains('hidden')) closeIntro();
else if(!document.getElementById('overlayWin').classList.contains('hidden')){ var nb=document.getElementById('btnNextLevel'); if(nb&&!nb.disabled) goNextLevel(); else restartLevel(); }
else if(!document.getElementById('overlayLoss').classList.contains('hidden')) restartLevel();
else if(state.status==='playing') tick();
}
if(e.code==='Backspace'&&state.status==='playing'){ e.preventDefault(); undoAction(); }
if(e.code==='Space'&&state.status==='playing'){ e.preventDefault(); deletePipeAtCursor(); }
if(e.code==='Space'&&(state.status==='lost'||state.status==='won')){ e.preventDefault(); restartLevel(); }
if((e.ctrlKey||e.metaKey)&&e.code==='KeyZ'){ e.preventDefault(); undoAction(); }
var DIRS={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]};
if(DIRS[e.code]!==undefined&&state.status==='playing'){
e.preventDefault();
var dr=DIRS[e.code][0], dc=DIRS[e.code][1], nr=state.cursor.r+dr, nc=state.cursor.c+dc;
if(nr>=0&&nc>=0&&nr<state.size&&nc<state.size){
if(e.shiftKey){ var curCell=state.board[state.cursor.r][state.cursor.c]; if(!state.drawing&&curCell.type!=='empty') onCellClick(state.cursor.r,state.cursor.c); state.cursor={r:nr,c:nc}; if(state.drawing) onCellClick(nr,nc); }
else state.cursor={r:nr,c:nc};
render();
}
}
if((e.key==='i'||e.key==='I'||e.key==='?')&&!e.ctrlKey&&!e.metaKey){
if(document.getElementById('overlayInfo').classList.contains('hidden')&&document.getElementById('overlayIntro').classList.contains('hidden')&&document.getElementById('overlayWin').classList.contains('hidden')&&document.getElementById('overlayLoss').classList.contains('hidden')){ e.preventDefault(); openInfo(); }
}
if(e.code==='Escape'){ if(!document.getElementById('overlayInfo').classList.contains('hidden')) closeInfo(); else if(state.drawing) cancelDrawing(); }
});
function resizeBoard() {
var sz = state.size || LEVELS[state.levelIndex].size;
var gap = 5, pad = 10;
var topbar = document.getElementById('topbar').offsetHeight || 40;
var ui = document.getElementById('ui').offsetHeight || 32;
var msg = document.getElementById('message').offsetHeight || 18;
var legend = document.getElementById('legend').offsetHeight || 0;
var controls = document.getElementById('controls').offsetHeight || 40;
var usedH = topbar + ui + msg + legend + controls + 12;
var avW = window.innerWidth;
var avH = Math.max(80, window.innerHeight - usedH);
var cell = Math.floor(Math.min(
(avW - pad*2 - gap*(sz-1)) / sz,
(avH - pad*2 - gap*(sz-1)) / sz
));
cell = Math.max(32, cell);
document.documentElement.style.setProperty('--cell', cell+'px');
}
window.addEventListener('resize', function() { resizeBoard(); renderPipes(); renderParticles(); });
loadLevel(0);
state.status='intro';
</script>
</body>
</html>

View file

@ -0,0 +1,113 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#000">
<rect width="400" height="220" fill="#000"/>
<rect x="10" y="10" width="380" height="200" rx="4" fill="#111" stroke="#222" stroke-width="1"/>
<!-- Grid cells -->
<rect x="22" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="22" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="82" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="22" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="82" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="142" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="202" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="262" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<rect x="322" y="142" width="52" height="52" rx="2" fill="#1c1c1c" stroke="#333" stroke-width="1"/>
<!-- Pipes (orange paths between nodes) -->
<path d="M48 74 L48 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M48 134 L48 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M74 48 L82 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M134 48 L142 48" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 74 L108 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M108 134 L108 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M194 108 L202 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M254 108 L262 108" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M228 74 L228 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 74 L288 82" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M288 134 L288 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M314 168 L322 168" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<path d="M168 134 L168 142" stroke="#FFA500" stroke-width="4" stroke-linecap="round" opacity="0.9"/>
<!-- PUB node (orange square) row1 col1 -->
<rect x="34" y="34" width="36" height="36" rx="2" fill="#FFA500" opacity="0.92"/>
<line x1="52" y1="24" x2="52" y2="34" stroke="#FFA500" stroke-width="2"/>
<circle cx="52" cy="21" r="3" fill="#FFA500"/>
<text x="52" y="57" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- habitante node (green circle) row1 col2 -->
<circle cx="108" cy="48" r="18" fill="#8BC34A" opacity="0.9"/>
<text x="108" y="52" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- validador (diamond) row1 col3 -->
<polygon points="168,30 186,48 168,66 150,48" fill="rgba(200,218,230,0.55)" stroke="#ccc" stroke-width="1"/>
<text x="168" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">VAL</text>
<!-- tienda (hexagon) row1 col4 -->
<polygon points="228,30 241,38 241,58 228,66 215,58 215,38" fill="#4CAF50" opacity="0.9"/>
<text x="228" y="52" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- acumulador (triangle) row1 col5 -->
<polygon points="288,30 310,66 266,66" fill="rgba(255,82,82,0.25)" stroke="#FF5252" stroke-width="1"/>
<text x="288" y="58" font-family="monospace" font-size="7" fill="#eee" text-anchor="middle" font-weight="bold">ACU</text>
<!-- CBDC (grey square) row1 col6 -->
<rect x="334" y="34" width="36" height="36" rx="2" fill="#607D8B" opacity="0.9"/>
<text x="352" y="57" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- row2 nodes -->
<!-- PUB node row2 col1 -->
<rect x="34" y="94" width="36" height="36" rx="2" fill="#FFA500" opacity="0.7"/>
<text x="52" y="117" font-family="monospace" font-size="9" fill="#000" text-anchor="middle" font-weight="bold">PUB</text>
<!-- hab row2 col2 -->
<circle cx="108" cy="108" r="18" fill="#8BC34A" opacity="0.7"/>
<text x="108" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- hab row2 col4 -->
<circle cx="228" cy="108" r="18" fill="#8BC34A" opacity="0.85"/>
<text x="228" y="112" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- tienda row2 col5 -->
<polygon points="288,90 301,98 301,118 288,126 275,118 275,98" fill="#4CAF50" opacity="0.75"/>
<text x="288" y="112" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- checkpoint row3 col2 -->
<rect x="90" y="154" width="36" height="36" rx="2" fill="none" stroke="#FFFF55" stroke-width="3"/>
<text x="108" y="177" font-family="monospace" font-size="7" fill="#FFFF55" text-anchor="middle" font-weight="bold">CHK</text>
<!-- hab row3 col3 -->
<circle cx="168" cy="168" r="18" fill="#8BC34A" opacity="0.8"/>
<text x="168" y="172" font-family="monospace" font-size="8" fill="#000" text-anchor="middle" font-weight="bold">HAB</text>
<!-- CBDC row3 col4 -->
<rect x="214" y="154" width="36" height="36" rx="2" fill="#607D8B" opacity="0.8"/>
<text x="232" y="177" font-family="monospace" font-size="8" fill="#cfd8dc" text-anchor="middle" font-weight="bold">CBD</text>
<!-- tienda row3 col6 -->
<polygon points="348,154 361,162 361,182 348,190 335,182 335,162" fill="#4CAF50" opacity="0.8"/>
<text x="348" y="176" font-family="monospace" font-size="7" fill="#000" text-anchor="middle" font-weight="bold">SHP</text>
<!-- Flow particles on some pipes -->
<circle cx="48" cy="78" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cy" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="91" cy="48" r="3" fill="#FFA500" opacity="0.95">
<animate attributeName="cx" values="74;82" dur="0.9s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;1;1;0" dur="0.9s" repeatCount="indefinite"/>
</circle>
<!-- Title -->
<text x="200" y="212" font-family="'Courier New', monospace" font-size="14" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">ECOINFLOW</text>
</svg>

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Labyrinth</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 24px; padding: 8px; font-size: 15px; color: #FFA500; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; border: 1px solid #333; }
#msg { font-size: 16px; color: #FFA500; margin: 4px; text-align: center; min-height: 22px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 6px; }
#dpad-row { display: flex; gap: 4px; }
#dpad button { width: 44px; height: 44px; background: #222; border: 1px solid #555; color: #FFA500; font-size: 18px; cursor: pointer; }
#dpad button:active { background: #444; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 4px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
#btn-giveup { margin-left: auto; background: #3a1a1a; border: 1px solid #f44; color: #f44; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 13px; }
#btn-giveup:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">LABYRINTH</span>
<button id="btn-giveup" disabled>Give Up</button>
</div>
<div id="ui">
<span>LEVEL: <b id="levelEl">1</b></span>
<span>MOVES: <b id="movesEl">150</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press SPACE or tap to start</div>
<div id="dpad">
<div id="dpad-row"><button id="btn-up">&#8593;</button></div>
<div id="dpad-row"><button id="btn-left">&#8592;</button><button id="btn-down">&#8595;</button><button id="btn-right">&#8594;</button></div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="labyrinth">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const BASE_SIZE = 10;
let CELL = 36;
let level = 1, moves = 0, maxMoves = 150, score = 0, total = 0, gameState = 'idle';
let cols, rows, grid, px, py;
let best = parseInt(localStorage.getItem('labyrinth_best') || '-1');
function seededRand(seed) {
let s = seed;
return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
}
function generateMaze(c, r, seed) {
const rng = seededRand(seed);
const cells = Array.from({length: r}, () => Array.from({length: c}, () => ({ n: true, s: true, e: true, w: true, visited: false })));
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function carve(x, y) {
cells[y][x].visited = true;
const dirs = shuffle([['n', 0, -1], ['s', 0, 1], ['e', 1, 0], ['w', -1, 0]]);
for (const [d, dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < c && ny >= 0 && ny < r && !cells[ny][nx].visited) {
const opp = { n: 's', s: 'n', e: 'w', w: 'e' };
cells[y][x][d] = false;
cells[ny][nx][opp[d]] = false;
carve(nx, ny);
}
}
}
carve(0, 0);
return cells;
}
function getAvailableSize() {
const usedH = (document.getElementById('topbar').offsetHeight || 40)
+ (document.getElementById('ui').offsetHeight || 30)
+ (document.getElementById('msg').offsetHeight || 22)
+ (document.getElementById('dpad').offsetHeight || 100)
+ (document.getElementById('controls').offsetHeight || 16)
+ 24;
return { w: window.innerWidth - 8, h: Math.max(200, window.innerHeight - usedH) };
}
function startLevel() {
cols = BASE_SIZE + (level - 1) * 2;
rows = Math.floor(cols * 0.7);
maxMoves = cols * rows * 2;
moves = 0;
grid = generateMaze(cols, rows, level * 7919 + 42);
px = Math.floor(cols / 2); py = Math.floor(rows / 2);
const avail = getAvailableSize();
CELL = Math.max(16, Math.floor(Math.min(avail.w / cols, avail.h / rows)));
canvas.width = cols * CELL;
canvas.height = rows * CELL;
document.getElementById('levelEl').textContent = level;
document.getElementById('movesEl').textContent = maxMoves;
document.getElementById('msg').textContent = `Level ${level} — Reach the exit!`;
gameState = 'play';
document.getElementById('btn-giveup').disabled = false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const cell = grid[py][px];
const dirs = { '0,-1': 'n', '0,1': 's', '1,0': 'e', '-1,0': 'w' };
const key = `${dx},${dy}`;
const wall = dirs[key];
if (!wall || cell[wall]) return;
px += dx; py += dy;
moves++;
document.getElementById('movesEl').textContent = maxMoves - moves;
if (px === cols - 1 && py === rows - 1) {
const remaining = maxMoves - moves;
const levelScore = remaining + level * 50;
total += levelScore;
document.getElementById('scoreEl').textContent = total;
document.getElementById('msg').textContent = `EXIT! +${levelScore} pts. Next level...`;
gameState = 'transit';
setTimeout(() => { level++; startLevel(); }, 1000);
} else if (moves >= maxMoves) {
endGame('moves');
}
}
function endGame(reason) {
gameState = 'over';
if (best < 0 || total > best) {
best = total;
localStorage.setItem('labyrinth_best', best);
document.getElementById('bestEl').textContent = best;
}
const head = reason === 'giveup' ? `Gave up at level ${level}.` : 'Out of moves!';
document.getElementById('msg').textContent = `${head} Score: ${total}. SPACE = new game`;
document.getElementById('scoreInput').value = total;
document.getElementById('scoreSubmit').style.display = 'block';
document.getElementById('btn-giveup').disabled = true;
}
function newGame() {
level = 1; total = 0; score = 0;
document.getElementById('scoreEl').textContent = '0';
document.getElementById('scoreSubmit').style.display = 'none';
startLevel();
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); newGame(); return; }
const map = { ArrowUp: [0,-1], ArrowDown: [0,1], ArrowLeft: [-1,0], ArrowRight: [1,0], KeyW: [0,-1], KeyS: [0,1], KeyA: [-1,0], KeyD: [1,0] };
if (map[e.code]) { e.preventDefault(); if (gameState === 'idle') newGame(); else tryMove(...map[e.code]); }
});
canvas.addEventListener('click', () => { if (gameState === 'idle') newGame(); });
window.addEventListener('resize', () => { if (gameState !== 'idle') startLevel(); });
const dpadStart = () => { if (gameState === 'idle') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
document.getElementById('btn-giveup').addEventListener('click', () => { if (gameState === 'play') endGame('giveup'); });
if (best >= 0) document.getElementById('bestEl').textContent = best;
const VIEW = 6;
function draw() {
const W = canvas.width, H = canvas.height;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
if (gameState === 'idle') { requestAnimationFrame(draw); return; }
const viewCols = Math.min(cols, Math.floor(W / CELL));
const viewRows = Math.min(rows, Math.floor(H / CELL));
const offX = Math.max(0, Math.min(px - Math.floor(viewCols / 2), cols - viewCols));
const offY = Math.max(0, Math.min(py - Math.floor(viewRows / 2), rows - viewRows));
for (let r = offY; r < Math.min(offY + viewRows + 1, rows); r++) {
for (let c = offX; c < Math.min(offX + viewCols + 1, cols); c++) {
const x = (c - offX) * CELL, y = (r - offY) * CELL;
const cell = grid[r][c];
ctx.strokeStyle = '#FFA500'; ctx.lineWidth = 2;
if (cell.n) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + CELL, y); ctx.stroke(); }
if (cell.s) { ctx.beginPath(); ctx.moveTo(x, y + CELL); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
if (cell.w) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + CELL); ctx.stroke(); }
if (cell.e) { ctx.beginPath(); ctx.moveTo(x + CELL, y); ctx.lineTo(x + CELL, y + CELL); ctx.stroke(); }
}
}
const ex = (cols - 1 - offX) * CELL + CELL / 2, ey = (rows - 1 - offY) * CELL + CELL / 2;
if (cols - 1 >= offX && cols - 1 < offX + viewCols && rows - 1 >= offY && rows - 1 < offY + viewRows) {
ctx.fillStyle = '#f44';
ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('EXIT', ex, ey);
}
const ppx = (px - offX) * CELL + CELL / 2, ppy = (py - offY) * CELL + CELL / 2;
ctx.fillStyle = '#00ff88';
ctx.beginPath(); ctx.arc(ppx, ppy, 7, 0, Math.PI * 2); ctx.fill();
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 80">
<rect width="120" height="80" fill="#111"/>
<g stroke="#FFA500" stroke-width="2" fill="none">
<rect x="5" y="5" width="110" height="70"/>
<line x1="25" y1="5" x2="25" y2="45"/>
<line x1="45" y1="35" x2="45" y2="75"/>
<line x1="65" y1="5" x2="65" y2="45"/>
<line x1="85" y1="35" x2="85" y2="75"/>
<line x1="5" y1="25" x2="45" y2="25"/>
<line x1="65" y1="25" x2="105" y2="25"/>
<line x1="25" y1="55" x2="65" y2="55"/>
<line x1="85" y1="55" x2="105" y2="55"/>
</g>
<circle cx="15" cy="15" r="4" fill="#00ff88"/>
<circle cx="105" cy="65" r="4" fill="#ff4444"/>
</svg>

After

Width:  |  Height:  |  Size: 672 B

View file

@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Neon Infiltrator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#ui { display: flex; gap: 16px; padding: 6px 8px; font-size: 14px; color: #FFA500; flex-wrap: wrap; justify-content: center; width: 100%; }
canvas { display: block; width: 100%; height: auto; max-width: 700px; border: 1px solid #333; image-rendering: crisp-edges; }
#msg { font-size: 13px; color: #FFA500; margin: 3px; text-align: center; min-height: 18px; }
#dpad { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 4px; }
.dpad-row { display: flex; gap: 4px; }
#dpad button { width: 50px; height: 50px; background: #1a0d00; border: 1px solid #FF6600; color: #FF6600; font-size: 20px; cursor: pointer; border-radius: 4px; }
#dpad button:active { background: #3a1a00; }
#controls { color: #666; font-size: 12px; text-align: center; margin-bottom: 3px; }
#scoreSubmit { text-align: center; margin: 6px; }
#scoreSubmit button { background: #1a3a1a; border: 1px solid #FFA500; color: #FFA500; padding: 6px 16px; cursor: pointer; font-family: monospace; font-size: 14px; }
@media (min-width: 768px) { #dpad, #controls { display: none; } }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">NEON INFILTRATOR</span>
</div>
<div id="ui">
<span>DATA: <b id="dataEl">0/0</b></span>
<span>LEVEL: <b id="levelEl">1</b></span>
<span>SCORE: <b id="scoreEl">0</b></span>
<span>BEST: <b id="bestEl">-</b></span>
<span>ENEMIES: <b id="enemyEl">0</b></span>
</div>
<canvas id="c"></canvas>
<div id="msg">Press any arrow key or tap to start</div>
<div id="dpad">
<div class="dpad-row"><button id="btn-up">&#8593;</button></div>
<div class="dpad-row">
<button id="btn-left">&#8592;</button>
<button id="btn-down">&#8595;</button>
<button id="btn-right">&#8594;</button>
</div>
</div>
<div id="controls">Arrow keys / WASD &nbsp;|&nbsp; SPACE — new game</div>
<div id="scoreSubmit" style="display:none">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="neoninfiltrator">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CELL = 28;
let MAP_W = 0, MAP_H = 0;
let walls = [], enemies = [], dataItems = [], exitPos = { x: -1, y: -1 };
let player = { x: 1, y: 1 };
let level = 1, dataCollected = 0, totalData = 0;
let score = 0;
let best = parseInt(localStorage.getItem('ni_best') || '-1');
let gameState = 'idle';
let lastPlayerMove = 0, lastEnemyStep = 0;
const MOVE_DELAY = 140, ENEMY_INTERVAL = 500;
const keys = {};
function setMsg(txt) { document.getElementById('msg').textContent = txt; }
function updateUI() {
document.getElementById('dataEl').textContent = dataCollected + '/' + totalData;
document.getElementById('levelEl').textContent = level;
document.getElementById('scoreEl').textContent = score;
document.getElementById('bestEl').textContent = best < 0 ? '-' : best;
document.getElementById('enemyEl').textContent = enemies.length;
}
function resizeCanvas() {
let bw = 18, bh = 14;
if (level >= 5) { bw = 22; bh = 18; }
else if (level >= 3) { bw = 20; bh = 16; }
MAP_W = bw; MAP_H = bh;
canvas.width = MAP_W * CELL;
canvas.height = MAP_H * CELL;
}
function generateLevel() {
resizeCanvas();
walls = Array(MAP_H).fill(null).map(() => Array(MAP_W).fill(false));
for (let i = 0; i < MAP_W; i++) { walls[0][i] = true; walls[MAP_H - 1][i] = true; }
for (let i = 0; i < MAP_H; i++) { walls[i][0] = true; walls[i][MAP_W - 1] = true; }
for (let i = 0; i < Math.floor(MAP_W * MAP_H / 7); i++) {
const x = 2 + Math.floor(Math.random() * (MAP_W - 4));
const y = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[y][x] && !(Math.abs(x - player.x) < 2 && Math.abs(y - player.y) < 2)) {
if (Math.random() < 0.55) walls[y][x] = true;
}
}
for (let i = 0; i < MAP_W * 2; i++) {
const x = 1 + Math.floor(Math.random() * (MAP_W - 2));
const y = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (walls[y][x] && x > 1 && x < MAP_W - 2 && y > 1 && y < MAP_H - 2) {
if (Math.random() < 0.5) walls[y][x] = false;
}
}
player.x = 1; player.y = 1;
if (walls[player.y][player.x]) {
outer: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x]) { player.x = x; player.y = y; break outer; }
}
}
}
exitPos = { x: MAP_W - 2, y: MAP_H - 2 };
for (let t = 0; t < 200; t++) {
const ex = MAP_W - 2 - Math.floor(Math.random() * 3);
const ey = MAP_H - 2 - Math.floor(Math.random() * 3);
if (ex >= 1 && ey >= 1 && !walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 6) {
exitPos = { x: ex, y: ey }; break;
}
}
totalData = Math.min(12, 3 + Math.floor(level / 1.5));
dataItems = [];
for (let i = 0; i < totalData; i++) {
let placed = false;
for (let t = 0; t < 100; t++) {
const dx = 1 + Math.floor(Math.random() * (MAP_W - 2));
const dy = 1 + Math.floor(Math.random() * (MAP_H - 2));
if (!walls[dy][dx] && !(dx === player.x && dy === player.y) && !(dx === exitPos.x && dy === exitPos.y) && !dataItems.some(d => d.x === dx && d.y === dy)) {
dataItems.push({ x: dx, y: dy, collected: false }); placed = true; break;
}
}
if (!placed) {
outer2: for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!walls[y][x] && !(x === player.x && y === player.y) && !(x === exitPos.x && y === exitPos.y) && !dataItems.some(d => d.x === x && d.y === y)) {
dataItems.push({ x, y, collected: false }); break outer2;
}
}
}
}
}
dataCollected = 0;
const enemyCount = Math.min(6, 1 + Math.floor(level / 2));
enemies = [];
for (let i = 0; i < enemyCount; i++) {
let placed = false;
for (let t = 0; t < 150; t++) {
const ex = 2 + Math.floor(Math.random() * (MAP_W - 4));
const ey = 2 + Math.floor(Math.random() * (MAP_H - 4));
if (!walls[ey][ex] && (Math.abs(ex - player.x) + Math.abs(ey - player.y)) > 4 && !(ex === exitPos.x && ey === exitPos.y) && !dataItems.some(d => d.x === ex && d.y === ey)) {
enemies.push({ x: ex, y: ey }); placed = true; break;
}
}
if (!placed) {
outer3: for (let y = 2; y < MAP_H - 2; y++) {
for (let x = 2; x < MAP_W - 2; x++) {
if (!walls[y][x] && (Math.abs(x - player.x) + Math.abs(y - player.y)) > 3 && !enemies.some(e => e.x === x && e.y === y)) {
enemies.push({ x, y }); break outer3;
}
}
}
}
}
updateUI();
setMsg(`Level ${level} — Collect ${totalData} data items and reach the exit`);
}
function checkEnemyCollision() {
for (const e of enemies) {
if (e.x === player.x && e.y === player.y) { endGame(); return true; }
}
return false;
}
function tryMove(dx, dy) {
if (gameState !== 'play') return;
const nx = player.x + dx, ny = player.y + dy;
if (nx < 0 || nx >= MAP_W || ny < 0 || ny >= MAP_H || walls[ny][nx]) return;
player.x = nx; player.y = ny;
for (const d of dataItems) {
if (!d.collected && d.x === player.x && d.y === player.y) {
d.collected = true; dataCollected++; score += 100;
updateUI(); setMsg(`Data collected! (${dataCollected}/${totalData})`); break;
}
}
if (dataCollected === totalData && exitPos.x === player.x && exitPos.y === player.y) {
const bonus = level * 50;
score += bonus;
updateUI();
setMsg(`Level ${level} complete! +${bonus} bonus. Advancing...`);
gameState = 'transit';
setTimeout(() => { level++; generateLevel(); gameState = 'play'; lastPlayerMove = performance.now(); lastEnemyStep = performance.now(); }, 1400);
return;
}
checkEnemyCollision();
}
function moveEnemies() {
if (gameState !== 'play') return;
for (const e of enemies) {
let dx = 0, dy = 0;
if (Math.random() < 0.6) {
if (Math.abs(e.x - player.x) > Math.abs(e.y - player.y)) dx = e.x > player.x ? -1 : 1;
else dy = e.y > player.y ? -1 : 1;
} else {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
[dx, dy] = dirs[Math.floor(Math.random() * 4)];
}
const nx = e.x + dx, ny = e.y + dy;
if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && !walls[ny][nx] && !(nx === exitPos.x && ny === exitPos.y)) {
e.x = nx; e.y = ny;
} else {
for (const [sx, sy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const bx = e.x + sx, by = e.y + sy;
if (bx >= 0 && bx < MAP_W && by >= 0 && by < MAP_H && !walls[by][bx] && !(bx === exitPos.x && by === exitPos.y)) {
e.x = bx; e.y = by; break;
}
}
}
}
checkEnemyCollision();
updateUI();
}
function endGame() {
gameState = 'over';
if (best < 0 || score > best) { best = score; localStorage.setItem('ni_best', best); }
updateUI();
setMsg(`DETECTED! Score: ${score}. Press SPACE for new game.`);
document.getElementById('scoreInput').value = score;
document.getElementById('scoreSubmit').style.display = 'block';
}
function newGame() {
score = 0; level = 1;
document.getElementById('scoreSubmit').style.display = 'none';
generateLevel();
gameState = 'play';
lastPlayerMove = performance.now();
lastEnemyStep = performance.now();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#1a0d00'; ctx.lineWidth = 0.6;
for (let i = 0; i <= MAP_W; i++) {
ctx.beginPath(); ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke();
}
for (let i = 0; i <= MAP_H; i++) {
ctx.beginPath(); ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke();
}
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (walls[y][x]) {
ctx.fillStyle = '#1a0a05';
ctx.fillRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
ctx.strokeStyle = '#FF6600'; ctx.lineWidth = 1;
ctx.strokeRect(x * CELL, y * CELL, CELL - 0.5, CELL - 0.5);
}
}
}
for (const d of dataItems) {
if (!d.collected) {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF6600';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.25, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#FF884D';
ctx.beginPath(); ctx.arc(d.x * CELL + CELL / 2, d.y * CELL + CELL / 2, CELL * 0.12, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
}
}
ctx.fillStyle = '#FFA500'; ctx.globalAlpha = 0.7;
ctx.fillRect(exitPos.x * CELL + 4, exitPos.y * CELL + 4, CELL - 8, CELL - 8);
ctx.globalAlpha = 1; ctx.fillStyle = '#FFA500';
ctx.font = `${CELL - 6}px monospace`;
ctx.fillText('\u25C8', exitPos.x * CELL + CELL * 0.28, exitPos.y * CELL + CELL * 0.78);
if (dataCollected === totalData && gameState === 'play') {
ctx.fillStyle = '#FFB347'; ctx.shadowBlur = 4; ctx.shadowColor = '#FF6600';
ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center';
ctx.fillText('\u2192 EXIT AVAILABLE \u2190', canvas.width / 2, 20);
ctx.shadowBlur = 0; ctx.textAlign = 'left';
}
for (const e of enemies) {
ctx.fillStyle = '#FF4400'; ctx.shadowBlur = 6; ctx.shadowColor = '#FF2200';
ctx.fillRect(e.x * CELL + 4, e.y * CELL + 4, CELL - 8, CELL - 8);
ctx.shadowBlur = 0;
}
ctx.fillStyle = '#FF6600'; ctx.shadowBlur = 8; ctx.shadowColor = '#FF6600';
ctx.fillRect(player.x * CELL + 6, player.y * CELL + 6, CELL - 12, CELL - 12);
ctx.fillStyle = '#FFB347';
ctx.fillRect(player.x * CELL + 10, player.y * CELL + 10, 5, 5);
ctx.shadowBlur = 0;
}
function gameLoop(now) {
if (gameState === 'play') {
let dx = 0, dy = 0;
if (keys.ArrowUp || keys.w) dy = -1;
if (keys.ArrowDown || keys.s) dy = 1;
if (keys.ArrowLeft || keys.a) dx = -1;
if (keys.ArrowRight || keys.d) dx = 1;
if ((dx !== 0 || dy !== 0) && now - lastPlayerMove >= MOVE_DELAY) {
tryMove(dx, dy); lastPlayerMove = now;
}
if (now - lastEnemyStep >= ENEMY_INTERVAL) { moveEnemies(); lastEnemyStep = now; }
}
draw();
requestAnimationFrame(gameLoop);
}
document.addEventListener('keydown', e => {
const key = e.key;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's', 'a', 'd', ' '].includes(key)) e.preventDefault();
if (key === ' ') { if (gameState === 'idle' || gameState === 'over') { newGame(); } return; }
keys[key] = true;
if (gameState === 'idle') newGame();
});
document.addEventListener('keyup', e => { keys[e.key] = false; });
const dpadStart = () => { if (gameState === 'idle' || gameState === 'over') newGame(); };
document.getElementById('btn-up').addEventListener('click', () => { dpadStart(); tryMove(0, -1); });
document.getElementById('btn-down').addEventListener('click', () => { dpadStart(); tryMove(0, 1); });
document.getElementById('btn-left').addEventListener('click', () => { dpadStart(); tryMove(-1, 0); });
document.getElementById('btn-right').addEventListener('click', () => { dpadStart(); tryMove(1, 0); });
generateLevel();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

View file

@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 220" style="background:#050505">
<rect width="400" height="220" fill="#050505"/>
<line x1="28" y1="10" x2="28" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="56" y1="10" x2="56" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="84" y1="10" x2="84" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="112" y1="10" x2="112" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="140" y1="10" x2="140" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="168" y1="10" x2="168" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="196" y1="10" x2="196" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="224" y1="10" x2="224" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="252" y1="10" x2="252" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="280" y1="10" x2="280" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="308" y1="10" x2="308" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="336" y1="10" x2="336" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="364" y1="10" x2="364" y2="195" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="38" x2="392" y2="38" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="66" x2="392" y2="66" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="94" x2="392" y2="94" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="122" x2="392" y2="122" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="150" x2="392" y2="150" stroke="#1a0d00" stroke-width="0.6"/>
<line x1="0" y1="178" x2="392" y2="178" stroke="#1a0d00" stroke-width="0.6"/>
<rect x="0" y="10" width="392" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="0" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="364.5" y="37.5" width="27.5" height="140" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="56" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="112" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="140" y="37.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="196" y="92.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="224" y="65" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="252" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="280" y="37.5" width="27.5" height="55" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="308" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="168" y="147.5" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<rect x="84" y="120" width="27.5" height="27.5" fill="#1a0a05" stroke="#FF6600" stroke-width="1"/>
<circle cx="71" cy="51" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="71" cy="51" r="3" fill="#FF884D"/>
<circle cx="183" cy="107" r="6" fill="#FFB347">
<animate attributeName="opacity" values="0.4;1;0.4" dur="1.2s" repeatCount="indefinite"/>
</circle>
<circle cx="183" cy="107" r="3" fill="#FF884D"/>
<circle cx="323" cy="79" r="6" fill="#FFB347">
<animate attributeName="opacity" values="1;0.4;1" dur="0.9s" repeatCount="indefinite"/>
</circle>
<circle cx="323" cy="79" r="3" fill="#FF884D"/>
<rect x="212" y="129" width="12" height="12" fill="#FFA500" opacity="0.7"/>
<text x="218" y="140" font-family="monospace" font-size="11" fill="#FFA500" text-anchor="middle">&#9672;</text>
<rect x="237" y="46" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="1;0.6;1" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="353" y="101" width="20" height="20" fill="#FF4400">
<animate attributeName="opacity" values="0.6;1;0.6" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="35" y="46" width="18" height="18" fill="#FF6600">
<animate attributeName="opacity" values="1;0.7;1" dur="1s" repeatCount="indefinite"/>
</rect>
<rect x="39" y="50" width="7" height="7" fill="#FFB347"/>
<text x="200" y="212" font-family="'Courier New', monospace" font-size="13" fill="#FFA500" text-anchor="middle" font-weight="bold" letter-spacing="3">NEON INFILTRATOR</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PingPong</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { overflow: hidden; }
body { background: #000; color: #eee; font-family: monospace; display: flex; flex-direction: column; align-items: center; height: 100vh; }
#topbar { width: 100%; padding: 8px 16px; display: flex; align-items: center; gap: 16px; background: #111; border-bottom: 1px solid #333; }
#topbar a { color: #FFA500; text-decoration: none; font-size: 14px; }
#topbar a:hover { text-decoration: underline; }
canvas { display: block; flex: 1; align-self: stretch; min-height: 0; width: 100%; background: #000; border: 1px solid #333; }
#msg { font-size: 18px; color: #FFA500; margin: 6px; text-align: center; min-height: 28px; }
#controls { color: #888; font-size: 13px; text-align: center; margin-bottom: 8px; }
</style>
</head>
<body>
<div id="topbar">
<a href="/games" target="_top">&#8592; Back to Games</a>
<span style="color:#FFA500;font-weight:bold">PINGPONG</span>
</div>
<canvas id="c" width="640" height="400"></canvas>
<div id="msg">Press SPACE to start</div>
<div id="controls">&#8593;&#8595; or W/S — Move paddle &nbsp;|&nbsp; First to 5 wins</div>
<div id="scoreSubmit" style="display:none;text-align:center;margin:6px">
<form method="POST" action="/games/submit-score" target="_top">
<input type="hidden" name="game" value="pingpong">
<input type="hidden" id="scoreInput" name="score" value="0">
<button type="submit" style="background:#1a3a1a;border:1px solid #FFA500;color:#FFA500;padding:6px 16px;cursor:pointer;font-family:monospace;font-size:14px">Submit Score to Hall of Fame</button>
</form>
</div>
<script>
document.addEventListener("keydown", e => { if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","KeyW","KeyA","KeyS","KeyD","Space"].includes(e.code)) e.preventDefault(); });
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PADDLE_W = 12, PADDLE_H = 70, PADDLE_OFF = 18;
const BALL_R = 8;
const WIN_SCORE = 5;
let state = 'idle';
let playerScore = 0, aiScore = 0;
let player, ai, ball;
let frames = 0;
function initGame() {
player = { x: PADDLE_OFF, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 5 };
ai = { x: W - PADDLE_OFF - PADDLE_W, y: H / 2 - PADDLE_H / 2, w: PADDLE_W, h: PADDLE_H, speed: 3.8 };
resetBall(1);
frames = 0;
}
function resetBall(dir) {
const angle = (Math.random() * 0.5 - 0.25) * Math.PI;
const speed = 5;
ball = {
x: W / 2, y: H / 2,
vx: dir * speed * Math.cos(angle),
vy: speed * Math.sin(angle),
r: BALL_R
};
}
const keys = {};
document.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'over') {
playerScore = 0; aiScore = 0;
initGame();
state = 'play';
document.getElementById('msg').textContent = '';
}
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
function paddleCollide(ball, pad) {
return ball.x - ball.r < pad.x + pad.w &&
ball.x + ball.r > pad.x &&
ball.y + ball.r > pad.y &&
ball.y - ball.r < pad.y + pad.h;
}
function loop() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = '#222';
ctx.lineWidth = 2;
ctx.setLineDash([12, 10]);
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.stroke();
ctx.setLineDash([]);
if (state === 'play') {
if ((keys['ArrowUp'] || keys['KeyW']) && player.y > 0) player.y -= player.speed;
if ((keys['ArrowDown'] || keys['KeyS']) && player.y + player.h < H) player.y += player.speed;
const aiCenter = ai.y + ai.h / 2;
const aiSpeed = ai.speed + Math.min(frames * 0.001, 1);
if (aiCenter < ball.y - 4) ai.y = Math.min(H - ai.h, ai.y + aiSpeed);
else if (aiCenter > ball.y + 4) ai.y = Math.max(0, ai.y - aiSpeed);
ball.x += ball.vx;
ball.y += ball.vy;
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = Math.abs(ball.vy); }
if (ball.y + ball.r > H) { ball.y = H - ball.r; ball.vy = -Math.abs(ball.vy); }
if (paddleCollide(ball, player)) {
ball.x = player.x + player.w + ball.r;
const rel = (ball.y - (player.y + player.h / 2)) / (player.h / 2);
const speed = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + 0.3, 14);
const angle = rel * 0.75;
ball.vx = speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (paddleCollide(ball, ai)) {
ball.x = ai.x - ball.r;
const rel = (ball.y - (ai.y + ai.h / 2)) / (ai.h / 2);
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
const angle = rel * 0.75;
ball.vx = -speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
}
if (ball.x - ball.r < 0) {
aiScore++;
if (aiScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'AI WINS — Press SPACE to retry'; }
else resetBall(1);
}
if (ball.x + ball.r > W) {
playerScore++;
if (playerScore >= WIN_SCORE) { state = 'over'; document.getElementById('msg').textContent = 'YOU WIN! — Press SPACE to play again'; document.getElementById('scoreInput').value = playerScore * 100; document.getElementById('scoreSubmit').style.display = 'block'; }
else resetBall(-1);
}
frames++;
}
ctx.fillStyle = '#FFA500';
ctx.beginPath(); ctx.roundRect(player.x, player.y, player.w, player.h, 4); ctx.fill();
ctx.fillStyle = '#4CAF50';
ctx.beginPath(); ctx.roundRect(ai.x, ai.y, ai.w, ai.h, 4); ctx.fill();
if (state !== 'idle') {
ctx.fillStyle = '#fff';
ctx.shadowBlur = 8;
ctx.shadowColor = '#fff';
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.font = 'bold 48px monospace';
ctx.textAlign = 'center';
ctx.fillStyle = '#FFA500';
ctx.fillText(playerScore, W / 4, 60);
ctx.fillStyle = '#4CAF50';
ctx.fillText(aiScore, W * 3 / 4, 60);
ctx.font = '13px monospace';
ctx.fillStyle = '#555';
ctx.fillText('YOU', W / 4, 78);
ctx.fillText('AI', W * 3 / 4, 78);
if (state === 'idle') {
ctx.font = '22px monospace';
ctx.fillStyle = '#FFA500';
ctx.fillText('PINGPONG', W / 2, H / 2 - 10);
ctx.font = '16px monospace';
ctx.fillStyle = '#888';
ctx.fillText('Press SPACE to start', W / 2, H / 2 + 20);
}
requestAnimationFrame(loop);
}
initGame();
loop();
</script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more