Switch to vite and preact

This commit is contained in:
Misode
2021-06-23 20:44:28 +02:00
parent e551b7ef75
commit 09c851914f
89 changed files with 6398 additions and 15531 deletions

75
.eslintrc.js Normal file
View File

@@ -0,0 +1,75 @@
module.exports = {
"env": {
"es6": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"tsconfigRootDir": __dirname,
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"ignorePatterns": [
"**/node_modules",
"**/dist",
".eslintrc.js",
"vite.config.js"
],
"rules": {
"@typescript-eslint/consistent-type-imports": [
"warn",
{
"prefer": "type-imports"
}
],
"@typescript-eslint/prefer-readonly": "warn",
"@typescript-eslint/quotes": [
"warn",
"single",
{
"avoidEscape": true
}
],
"@typescript-eslint/semi": [
"warn",
"never"
],
"@typescript-eslint/indent": [
"warn",
"tab"
],
"@typescript-eslint/member-delimiter-style": [
"warn",
{
"multiline": {
"delimiter": "comma",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
},
"overrides": {
"interface": {
"multiline": {
"delimiter": undefined
}
}
}
}
],
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": ["warn", "always-multiline"],
"indent": "off",
"eol-last": "warn",
"no-fallthrough": "warn",
"prefer-const": "warn",
"prefer-object-spread": "warn",
"quote-props": [
"warn",
"as-needed"
]
}
}

14
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll.eslint": true
}
},
"[typescriptreact]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll.eslint": true
}
}
}

28
index.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-73024255-2', 'auto');
ga('set', 'page', location.pathname);
ga('set', 'dimension1', localStorage.getItem('theme') ?? 'default');
ga('set', 'dimension2', 'v2');
ga('set', 'dimension3', localStorage.getItem('schema_version') ?? '1.17');
ga('set', 'dimension4', localStorage.getItem('language') ?? 'en');
ga('set', 'dimension5', 'none');
ga('send', 'pageview');
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Pack Generators Minecraft 1.15, 1.16, 1.17</title>
<link rel="icon" href="/src/favicon-32.png" sizes="32x32">
<script async src="https://media.ethicalads.io/media/client/ethicalads.min.js"></script>
</head>
<body>
<div data-ea-publisher="misode-github-io" data-ea-manual="true" id="ad-placeholder"></div>
<script src="./src/app/Main.tsx" type="module"></script>
</body>
</html>

14924
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,10 @@
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack-cli -p",
"start": "webpack-dev-server -d --content-base ./public --host 0.0.0.0"
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint . --ext .ts,.tsx"
},
"keywords": [],
"author": "Misode",
@@ -17,18 +19,21 @@
"@mcschema/java-1.16": "^0.6.3",
"@mcschema/java-1.17": "^0.2.23",
"@mcschema/locales": "^0.1.20",
"seedrandom": "^3.0.5",
"split.js": "^1.5.11"
},
"devDependencies": {
"@preact/preset-vite": "^2.1.0",
"@rollup/plugin-html": "^0.2.3",
"@types/google.analytics": "0.0.40",
"@types/seedrandom": "^2.4.28",
"@types/split.js": "^1.4.0",
"copy-webpack-plugin": "^6.0.1",
"html-webpack-plugin": "^4.3.0",
"merge-jsons-webpack-plugin": "^1.0.21",
"seedrandom": "^3.0.5",
"split.js": "^1.5.11",
"ts-loader": "^7.0.4",
"@typescript-eslint/eslint-plugin": "^4.25.0",
"@typescript-eslint/parser": "^4.25.0",
"eslint": "^7.27.0",
"preact": "^10.5.13",
"preact-router": "^3.2.1",
"typescript": "^4.1.3",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0"
"vite": "^2.3.7"
}
}

46
src/app/Analytics.ts Normal file
View File

@@ -0,0 +1,46 @@
export namespace Analytics {
const ID_SITE = 'Site'
const ID_GENERATOR = 'Generator'
const DIM_THEME = 1
const DIM_VERSION = 3
const DIM_LANGUAGE = 4
const DIM_PREVIEW = 5
function event(category: string, action: string, label?: string) {
ga('send', 'event', category, action, label)
}
function dimension(index: number, value: string) {
ga('set', `dimension${index}`, value)
}
export function pageview(page: string) {
ga('set', 'page', page)
ga('send', 'pageview')
}
export function setLanguage(language: string) {
dimension(DIM_LANGUAGE, language)
event(ID_SITE, 'set-language', language)
}
export function setTheme(theme: string) {
dimension(DIM_THEME, theme)
event(ID_SITE, 'set-theme', theme)
}
export function setVersion(version: string) {
dimension(DIM_VERSION, version)
event(ID_GENERATOR, 'set-version', version)
}
export function setPreview(preview: string) {
dimension(DIM_PREVIEW, preview)
event(ID_GENERATOR, 'set-preview', preview)
}
export function generatorEvent(action: string, label?: string) {
event(ID_GENERATOR, action, label)
}
}

View File

@@ -1,171 +0,0 @@
import { CollectionRegistry, DataModel, ObjectNode, SchemaRegistry } from '@mcschema/core';
import * as java15 from '@mcschema/java-1.15'
import * as java16 from '@mcschema/java-1.16'
import * as java17 from '@mcschema/java-1.17'
import { LocalStorageProperty } from './state/LocalStorageProperty';
import { Property } from './state/Property';
import { Preview } from './preview/Preview';
import { fetchData } from './DataFetcher';
import { BiomeNoisePreview } from './preview/BiomeNoisePreview';
import { NoiseSettingsPreview } from './preview/NoiseSettingsPreview';
import { DecoratorPreview } from './preview/DecoratorPreview';
import config from '../config.json';
import { locale, Locales } from './Locales';
import { Tracker } from './Tracker';
import { Settings } from './Settings';
export const Versions: {
[versionId: string]: {
getCollections: () => CollectionRegistry,
getSchemas: (collections: CollectionRegistry) => SchemaRegistry,
}
} = {
'1.15': java15,
'1.16': java16,
'1.17': java17
}
export const Previews: {
[key: string]: Preview
} = {
'biome_noise': new BiomeNoisePreview(),
'noise_settings': new NoiseSettingsPreview(),
'decorator': new DecoratorPreview(),
}
export const Models: {
[key: string]: DataModel
} = {}
config.models.filter(m => m.schema)
.forEach(m => Models[m.id] = new DataModel(ObjectNode({})))
export type BlockStateRegistry = {
[block: string]: {
properties: {
[key: string]: string[]
},
default: {
[key: string]: string
}
}
}
export const App = {
version: new LocalStorageProperty('schema_version', config.versions[config.versions.length - 1].id)
.watch(Tracker.dimVersion),
theme: new LocalStorageProperty('theme', 'dark')
.watch(Tracker.dimTheme),
language: new LocalStorageProperty('language', 'en')
.watch(Tracker.dimLanguage),
model: new Property<typeof config.models[0] | null>(null),
collections: new Property<CollectionRegistry | null>(null),
jsonOutput: new Property(''),
errorsVisible: new Property(false),
treeMinimized: new Property(false),
jsonError: new Property<string | null>(null),
preview: new Property<Preview | null>(null)
.watch(p => Tracker.dimPreview(p?.getName() ?? 'none')),
schemasLoaded: new Property(false),
localesLoaded: new Property(false),
loaded: new Property(false),
mobilePanel: new Property('tree'),
settings: new Settings('generator_settings'),
blockStateRegistry: {} as BlockStateRegistry
}
console.debug(`[App] LocalStorage=${'localStorage' in window} Caches=${'caches' in window}`)
App.version.watchRun(async (value) => {
console.debug(`[App.version.watchRun] ${value}`)
App.schemasLoaded.set(false)
await updateSchemas(value)
App.schemasLoaded.set(true)
console.debug(`[App.version.watchRun] Done! ${value}`)
})
App.theme.watchRun((value) => {
console.debug(`[App.theme.watchRun] ${value}`)
document.documentElement.setAttribute('data-theme', value)
})
let hasFetchedEnglish = false
App.language.watchRun(async (value) => {
console.debug(`[App.language.watchRun] ${value}`)
App.localesLoaded.set(false)
await updateLocale(value)
App.localesLoaded.set(true)
console.debug(`[App.language.watchRun] Done! ${value}`)
})
App.localesLoaded.watch((value) => {
console.debug(`[App.localesLoaded.watch] ${value}`)
if (value) {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = locale(el.attributes.getNamedItem('data-i18n')!.value)
})
}
App.loaded.set(value && App.schemasLoaded.get())
})
App.schemasLoaded.watch((value) => {
console.debug(`[App.schemasLoaded.watch] ${value}`)
App.loaded.set(value && App.localesLoaded.get())
})
App.mobilePanel.watchRun((value) => {
console.debug(`[App.mobilePanel.watchRun] ${value}`)
document.body.setAttribute('data-panel', value)
})
async function updateSchemas(version: string) {
console.debug(`[updateSchemas] ${version}`)
App.blockStateRegistry = {}
const collections = Versions[version].getCollections()
console.debug(`[updateSchemas] Done getting collections! ${Object.keys(collections['registry']).length}`)
App.collections.set(collections)
await fetchData(collections, version)
console.debug('[updateSchemas] Done fetching data!')
const schemas = Versions[version].getSchemas(collections)
console.debug(`[updateSchemas] Done getting schemas! ${Object.keys(schemas['registry']).length}`)
config.models
.filter(m => m.schema)
.filter(m => checkVersion(App.version.get(), m.minVersion))
.forEach(m => {
const model = Models[m.id]
const schema = schemas.get(m.schema!)
if (schema) {
model.schema = schema
if (JSON.stringify(model.data) === '{}') {
model.reset(schema.default(), true)
model.history = [JSON.stringify(model.data)]
model.historyIndex = 0
}
}
})
console.debug(`[updateSchemas] Done!`)
}
async function updateLocale(language: string) {
if (Locales[language] && (hasFetchedEnglish || language !== 'en')) return
const data = await (await fetch(`/locales/${language}.json`)).json()
if (language === 'en') hasFetchedEnglish = true
Locales[language] = data
}
export function checkVersion(versionId: string, minVersionId: string | undefined, maxVersionId?: string) {
const version = config.versions.findIndex(v => v.id === versionId)
const minVersion = minVersionId ? config.versions.findIndex(v => v.id === minVersionId) : 0
const maxVersion = maxVersionId ? config.versions.findIndex(v => v.id === maxVersionId) : config.versions.length - 1
return minVersion <= version && version <= maxVersion
}
document.addEventListener('keyup', (evt) => {
if (evt.ctrlKey && evt.key === 'z') {
Tracker.undo(true)
Models[App.model.get()!.id].undo()
} else if (evt.ctrlKey && evt.key === 'y') {
Tracker.redo(true)
Models[App.model.get()!.id].redo()
}
})

View File

@@ -1,206 +1,209 @@
import { CollectionRegistry } from '@mcschema/core'
import { App, BlockStateRegistry, checkVersion } from './App'
import type { CollectionRegistry } from '@mcschema/core'
import config from '../config.json'
import type { BlockStateRegistry, VersionId } from './Schemas'
import { checkVersion } from './Schemas'
['1.15', '1.16', '1.17'].forEach(v => localStorage.removeItem(`cache_${v}`))
const CACHE_NAME = `misode-v1`
const CACHE_NAME = 'misode-v1'
type VersionRef = 'mcdata_master' | 'vanilla_datapack_summary' | 'vanilla_datapack_data'
type Version = {
id: string,
refs: Partial<{ [key in VersionRef]: string }>,
dynamic?: boolean,
id: string,
refs: Partial<{ [key in VersionRef]: string }>,
dynamic?: boolean,
}
declare var __MCDATA_MASTER_HASH__: string;
declare var __VANILLA_DATAPACK_SUMMARY_HASH__: string;
declare var __MCDATA_MASTER_HASH__: string
declare var __VANILLA_DATAPACK_SUMMARY_HASH__: string
const mcdataUrl = 'https://raw.githubusercontent.com/Arcensoth/mcdata'
const vanillaDatapackUrl = 'https://raw.githubusercontent.com/SPGoding/vanilla-datapack'
const refs: {
id: VersionRef,
hash: string,
url: string
id: VersionRef,
hash: string,
url: string,
}[] = [
{
id: 'mcdata_master',
hash: __MCDATA_MASTER_HASH__,
url: mcdataUrl
},
{
id: 'vanilla_datapack_summary',
hash: __VANILLA_DATAPACK_SUMMARY_HASH__,
url: vanillaDatapackUrl
},
{
id: 'mcdata_master',
hash: __MCDATA_MASTER_HASH__,
url: mcdataUrl,
},
{
id: 'vanilla_datapack_summary',
hash: __VANILLA_DATAPACK_SUMMARY_HASH__,
url: vanillaDatapackUrl,
},
]
export async function fetchData(target: CollectionRegistry, versionId: string) {
const version = config.versions.find(v => v.id === versionId) as Version | undefined
if (!version) {
console.error(`[fetchData] Unknown version ${version} in ${JSON.stringify(config.versions)}`)
return
}
console.debug(`[fetchData] ${JSON.stringify(version)}`)
export async function fetchData(versionId: string, collectionTarget: CollectionRegistry, blockStateTarget: BlockStateRegistry) {
const version = config.versions.find(v => v.id === versionId) as Version | undefined
if (!version) {
console.error(`[fetchData] Unknown version ${version} in ${JSON.stringify(config.versions)}`)
return
}
console.debug(`[fetchData] ${JSON.stringify(version)}`)
if (version.dynamic) {
await Promise.all(refs
.filter(r => localStorage.getItem(`cached_${r.id}`) !== r.hash)
.map(async r => {
console.debug(`[deleteMatching] ${r.id} '${localStorage.getItem(`cached_${r.id}`)}' < '${r.hash}' ${r.url}/${version.refs[r.id]}`)
await deleteMatching(url => url.startsWith(`${r.url}/${version.refs[r.id]}`))
console.debug(`[deleteMatching] Done! ${r.id} ${r.hash} '${localStorage.getItem(`cached_${r.id}`)}'`)
localStorage.setItem(`cached_${r.id}`, r.hash)
console.debug(`[deleteMatching] Set! ${r.id} ${r.hash} '${localStorage.getItem(`cached_${r.id}`)}'`)
}))
}
if (version.dynamic) {
await Promise.all(refs
.filter(r => localStorage.getItem(`cached_${r.id}`) !== r.hash)
.map(async r => {
console.debug(`[deleteMatching] ${r.id} '${localStorage.getItem(`cached_${r.id}`)}' < '${r.hash}' ${r.url}/${version.refs[r.id]}`)
await deleteMatching(url => url.startsWith(`${r.url}/${version.refs[r.id]}`))
console.debug(`[deleteMatching] Done! ${r.id} ${r.hash} '${localStorage.getItem(`cached_${r.id}`)}'`)
localStorage.setItem(`cached_${r.id}`, r.hash)
console.debug(`[deleteMatching] Set! ${r.id} ${r.hash} '${localStorage.getItem(`cached_${r.id}`)}'`)
}))
}
await Promise.all([
fetchRegistries(version, target),
fetchBlockStateMap(version),
fetchDynamicRegistries(version, target)
])
await Promise.all([
fetchRegistries(version, collectionTarget),
fetchBlockStateMap(version, blockStateTarget),
fetchDynamicRegistries(version, collectionTarget),
])
}
async function fetchRegistries(version: Version, target: CollectionRegistry) {
console.debug(`[fetchRegistries] ${version.id}`)
const registries = config.registries
.filter(r => !r.dynamic)
.filter(r => checkVersion(version.id, r.minVersion, r.maxVersion))
console.debug(`[fetchRegistries] ${version.id}`)
const registries = config.registries
.filter(r => !r.dynamic)
.filter(r => checkVersion(version.id, r.minVersion, r.maxVersion))
if (checkVersion(version.id, undefined, '1.15')) {
const url = `${mcdataUrl}/${version.refs.mcdata_master}/generated/reports/registries.json`
try {
const data = await getData(url, (data) => {
const res: {[id: string]: string[]} = {}
Object.keys(data).forEach(k => {
res[k.slice(10)] = Object.keys(data[k].entries)
})
return res
})
registries.forEach(r => {
target.register(r.id, data[r.id] ?? [])
})
} catch (e) {
console.warn(`Error occurred while fetching registries:`, e)
}
} else {
return Promise.all(registries.map(async r => {
try {
const url = r.path
? `${mcdataUrl}/${version.refs.mcdata_master}/${r.path}/data.min.json`
: `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/registries/${r.id}/data.min.json`
target.register(r.id, await getData(url, v => v.values))
} catch (e) {
console.warn(`Error occurred while fetching registry ${r.id}:`, e)
}
}))
}
if (checkVersion(version.id, undefined, '1.15')) {
const url = `${mcdataUrl}/${version.refs.mcdata_master}/generated/reports/registries.json`
try {
const data = await getData(url, (data) => {
const res: {[id: string]: string[]} = {}
Object.keys(data).forEach(k => {
res[k.slice(10)] = Object.keys(data[k].entries)
})
return res
})
registries.forEach(r => {
target.register(r.id, data[r.id] ?? [])
})
} catch (e) {
console.warn('Error occurred while fetching registries:', e)
}
} else {
await Promise.all(registries.map(async r => {
try {
const url = r.path
? `${mcdataUrl}/${version.refs.mcdata_master}/${r.path}/data.min.json`
: `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/registries/${r.id}/data.min.json`
target.register(r.id, await getData(url, v => v.values))
} catch (e) {
console.warn(`Error occurred while fetching registry ${r.id}:`, e)
}
}))
}
}
async function fetchBlockStateMap(version: Version) {
console.debug(`[fetchBlockStateMap] ${version.id}`)
if (checkVersion(version.id, undefined, '1.16')) {
const url = (checkVersion(version.id, undefined, '1.15'))
? `${mcdataUrl}/${version.refs.mcdata_master}/generated/reports/blocks.json`
: `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/blocks/data.min.json`
async function fetchBlockStateMap(version: Version, target: BlockStateRegistry) {
console.debug(`[fetchBlockStateMap] ${version.id}`)
if (checkVersion(version.id, undefined, '1.16')) {
const url = (checkVersion(version.id, undefined, '1.15'))
? `${mcdataUrl}/${version.refs.mcdata_master}/generated/reports/blocks.json`
: `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/blocks/data.min.json`
try {
const data = await getData(url, (data) => {
const res: BlockStateRegistry = {}
Object.keys(data).forEach(b => {
res[b] = {
properties: data[b].properties,
default: data[b].states.find((s: any) => s.default).properties
}
})
return res
})
App.blockStateRegistry = data
} catch (e) {
console.warn(`Error occurred while fetching block state map:`, e)
}
} else {
const url = `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/blocks/simplified/data.min.json`
try {
App.blockStateRegistry = await getData(url)
} catch (e) {
console.warn(`Error occurred while fetching block state map:`, e)
}
}
try {
const data = await getData(url, (data) => {
const res: BlockStateRegistry = {}
Object.keys(data).forEach(b => {
res[b] = {
properties: data[b].properties,
default: data[b].states.find((s: any) => s.default).properties,
}
})
return res
})
Object.assign(target, data)
} catch (e) {
console.warn('Error occurred while fetching block state map:', e)
}
} else {
const url = `${mcdataUrl}/${version.refs.mcdata_master}/processed/reports/blocks/simplified/data.min.json`
try {
const data = await getData(url)
Object.assign(target, data)
} catch (e) {
console.warn('Error occurred while fetching block state map:', e)
}
}
}
async function fetchDynamicRegistries(version: Version, target: CollectionRegistry) {
console.debug(`[fetchDynamicRegistries] ${version.id}`)
const registries = config.registries
.filter(r => r.dynamic)
.filter(r => checkVersion(version.id, r.minVersion, r.maxVersion))
console.debug(`[fetchDynamicRegistries] ${version.id}`)
const registries = config.registries
.filter(r => r.dynamic)
.filter(r => checkVersion(version.id, r.minVersion, r.maxVersion))
if (checkVersion(version.id, '1.16')) {
const url = `${vanillaDatapackUrl}/${version.refs.vanilla_datapack_summary}/summary/flattened.min.json`
try {
const data = await getData(url)
registries.forEach(r => {
target.register(r.id, data[r.id])
})
} catch (e) {
console.warn(`Error occurred while fetching dynamic registries:`, e)
}
}
if (checkVersion(version.id, '1.16')) {
const url = `${vanillaDatapackUrl}/${version.refs.vanilla_datapack_summary}/summary/flattened.min.json`
try {
const data = await getData(url)
registries.forEach(r => {
target.register(r.id, data[r.id])
})
} catch (e) {
console.warn('Error occurred while fetching dynamic registries:', e)
}
}
}
export async function fetchPreset(version: Version, registry: string, id: string) {
console.debug(`[fetchPreset] ${version.id} ${registry} ${id}`)
try {
const res = await fetch(`${vanillaDatapackUrl}/${version.refs.vanilla_datapack_data}/data/minecraft/${registry}/${id}.json`)
return await res.json()
} catch (e) {
console.warn(`Error occurred while fetching ${registry} preset ${id}:`, e)
}
export async function fetchPreset(version: VersionId, registry: string, id: string) {
console.debug(`[fetchPreset] ${id} ${registry} ${id}`)
const versionData = config.versions.find(v => v.id === version)!
try {
const res = await fetch(`${vanillaDatapackUrl}/${versionData.refs.vanilla_datapack_data}/data/minecraft/${registry}/${id}.json`)
return await res.json()
} catch (e) {
console.warn(`Error occurred while fetching ${registry} preset ${id}:`, e)
}
}
async function getData<T = any>(url: string, fn: (v: any) => T = (v: any) => v): Promise<T> {
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[getData] Opened cache ${CACHE_NAME} ${url}`)
const cacheResponse = await cache.match(url)
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[getData] Opened cache ${CACHE_NAME} ${url}`)
const cacheResponse = await cache.match(url)
if (cacheResponse && cacheResponse.ok) {
console.debug(`[getData] Retrieving cached data ${url}`)
return await cacheResponse.json()
}
if (cacheResponse && cacheResponse.ok) {
console.debug(`[getData] Retrieving cached data ${url}`)
return await cacheResponse.json()
}
console.debug(`[getData] fetching data ${url}`)
const fetchResponse = await fetch(url)
const responseData = fn(await fetchResponse.json())
await cache.put(url, new Response(JSON.stringify(responseData)))
return responseData
} catch (e) {
console.warn(`[getData] Failed to open cache ${CACHE_NAME}: ${e.message}`)
console.debug(`[getData] fetching data ${url}`)
const fetchResponse = await fetch(url)
const responseData = fn(await fetchResponse.json())
await cache.put(url, new Response(JSON.stringify(responseData)))
return responseData
} catch (e) {
console.warn(`[getData] Failed to open cache ${CACHE_NAME}: ${e.message}`)
console.debug(`[getData] fetching data ${url}`)
const fetchResponse = await fetch(url)
const responseData = fn(await fetchResponse.json())
return responseData
}
console.debug(`[getData] fetching data ${url}`)
const fetchResponse = await fetch(url)
const responseData = fn(await fetchResponse.json())
return responseData
}
}
async function deleteMatching(matches: (url: string) => boolean) {
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[deleteMatching] Opened cache ${CACHE_NAME}`)
const promises: Promise<boolean>[] = []
try {
const cache = await caches.open(CACHE_NAME)
console.debug(`[deleteMatching] Opened cache ${CACHE_NAME}`)
const promises: Promise<boolean>[] = []
for (const request of await cache.keys()) {
if (matches(request.url)) {
promises.push(cache.delete(request))
}
}
console.debug(`[deleteMatching] Removing ${promises.length} cache objects...`)
await Promise.all(promises)
} catch (e) {
console.warn(`[deleteMatching] Failed to open cache ${CACHE_NAME}: ${e.message}`)
}
for (const request of await cache.keys()) {
if (matches(request.url)) {
promises.push(cache.delete(request))
}
}
console.debug(`[deleteMatching] Removing ${promises.length} cache objects...`)
await Promise.all(promises)
} catch (e) {
console.warn(`[deleteMatching] Failed to open cache ${CACHE_NAME}: ${e.message}`)
}
}

View File

@@ -1,39 +1,36 @@
import config from '../config.json'
import English from '../locales/en.json'
import { App } from './App'
export type Localize = (key: string, ...params: string[]) => string
interface Locale {
[key: string]: string
[key: string]: string
}
export const Locales: {
[key: string]: Locale
[key: string]: Locale,
} = {
'en': English
fallback: English,
}
export function resolveLocaleParams(value: string, params?: string[]): string | undefined {
return value?.replace(/%\d+%/g, match => {
const index = parseInt(match.slice(1, -1))
return params?.[index] !== undefined ? params[index] : match
})
function resolveLocaleParams(value: string, params?: string[]): string {
return value.replace(/%\d+%/g, match => {
const index = parseInt(match.slice(1, -1))
return params?.[index] !== undefined ? params[index] : match
})
}
export function locale(key: string, params?: string[]): string {
const value: string | undefined = Locales[App.language.get()]?.[key] ?? Locales.en[key]
return resolveLocaleParams(value, params) ?? key
export function locale(language: string, key: string, ...params: string[]): string {
const value: string | undefined = Locales[language]?.[key]
?? Locales.en?.[key] ?? Locales.fallback[key] ?? key
return resolveLocaleParams(value, params)
}
export function segmentedLocale(segments: string[], params?: string[], depth = 5, minDepth = 1): string | undefined {
return [App.language.get(), 'en'].reduce((prev: string | undefined, code) => {
if (prev !== undefined) return prev
const array = segments.slice(-depth);
while (array.length >= minDepth) {
const locale = resolveLocaleParams(Locales[code]?.[array.join('.')], params)
if (locale !== undefined) return locale
array.shift()
}
return undefined
}, undefined)
export async function loadLocale(language: string) {
const langConfig = config.languages.find(lang => lang.code === language)
if (!langConfig) return
const data = await import(`../locales/${language}.json`)
const schema = langConfig.schemas !== false
&& await import(`../../node_modules/@mcschema/locales/src/${language}.json`)
Locales[language] = { ...data, ...schema, ...data.default, ...schema.default }
}

78
src/app/Main.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { render } from 'preact'
import type { RouterOnChangeArgs } from 'preact-router'
import { Router } from 'preact-router'
import { useEffect, useState } from 'preact/hooks'
import '../styles/global.css'
import '../styles/nodes.css'
import { Analytics } from './Analytics'
import { Header } from './components'
import { loadLocale, locale, Locales } from './Locales'
import { FieldSettings } from './pages/FieldSettings'
import { Generator } from './pages/Generator'
import { Home } from './pages/Home'
import type { VersionId } from './Schemas'
import { Store } from './Store'
import { cleanUrl } from './Utils'
function Main() {
const [lang, setLanguage] = useState<string>('en')
const changeLanguage = async (language: string) => {
if (!Locales[language]) {
await loadLocale(language)
}
Analytics.setLanguage(language)
Store.setLanguage(language)
setLanguage(language)
}
useEffect(() => {
(async () => {
const target = Store.getLanguage()
await Promise.all([
loadLocale('en'),
...(target !== 'en' ? [loadLocale(target)] : []),
])
setLanguage(target)
})()
}, [])
const [theme, setTheme] = useState<string>(Store.getTheme())
const changeTheme = (theme: string) => {
Analytics.setTheme(theme)
Store.setTheme(theme)
setTheme(theme)
}
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
const [version, setVersion] = useState<VersionId>(Store.getVersion())
const changeVersion = (version: VersionId) => {
Analytics.setVersion(version)
Store.setVersion(version)
setVersion(version)
}
const [title, setTitle] = useState<string>(locale(lang, 'title.home'))
const changeTitle = (title: string, versions = ['1.15', '1.16', '1.17']) => {
document.title = `${title} Minecraft ${versions.join(', ')}`
setTitle(title)
}
const changeRoute = (e: RouterOnChangeArgs) => {
// Needs a timeout to ensure the title is set correctly
setTimeout(() => Analytics.pageview(cleanUrl(e.url)))
}
return <>
<Header {...{lang, title, theme, language: lang, changeLanguage, changeTheme}} />
<Router onChange={changeRoute}>
<Home path="/" {...{lang, changeTitle}} />
<FieldSettings path="/settings/fields" {...{lang, changeTitle}} />
<Home path="/worldgen" category="worldgen" {...{lang, changeTitle}} />
<Generator path="/:generator" {...{lang, version, changeTitle}} onChangeVersion={changeVersion} />
<Generator path="/worldgen/:generator" category="worldgen" {...{lang, version, changeTitle}} onChangeVersion={changeVersion} />
</Router>
</>
}
render(<Main />, document.body)

View File

@@ -1,83 +0,0 @@
import { App, checkVersion, Models } from './App';
import { View } from './views/View';
import { Home } from './views/Home'
import { NotFound } from './views/NotFound'
import { FieldSettings } from './views/FieldSettings'
import { Generator } from './views/Generator'
import { locale } from './Locales';
import { Tracker } from './Tracker';
import config from '../config.json'
const categories = config.models.filter(m => m.category === true)
const router = async () => {
localStorage.length
const urlParts = location.pathname.split('/').filter(e => e)
const urlParams = new URLSearchParams(location.search)
console.debug(`[router] ${urlParts.join('/')}`)
const target = document.getElementById('app')!
let title = locale('title.home')
let renderer = (view: View) => ''
let panel = 'home'
if (urlParts.length === 0){
App.model.set({ id: '', name: 'Data Pack', category: true, minVersion: '1.15'})
renderer = Home
} else if (urlParts[0] === 'settings' && urlParts[1] === 'fields') {
panel = 'settings'
renderer = FieldSettings
} else if (urlParts.length === 1 && categories.map(m => m.id).includes(urlParts[0])) {
App.model.set(categories.find(m => m.id === urlParts[0])!)
renderer = Home
} else {
panel = 'tree'
const model = config.models.find(m => m.id === urlParts.join('/')) ?? null
App.model.set(model)
if (model) {
if (urlParams.has('q')) {
try {
const data = atob(urlParams.get('q') ?? '')
Models[model.id].reset(JSON.parse(data))
} catch (e) {}
}
renderer = Generator
title = locale('title.generator', [locale(model.id)])
} else {
renderer = NotFound
}
}
console.debug(`[router] Renderer=${renderer.name}`)
const versions = config.versions
.filter(v => checkVersion(v.id, App.model.get()?.minVersion))
.map(v => v.id).join(', ')
document.title = `${title} Minecraft ${versions}`
console.debug(`[router] Title=${title} Versions=${versions}`)
App.mobilePanel.set(panel)
const view = new View()
view.mount(target, renderer(view), true)
console.debug(`[router] Done!`)
}
window.addEventListener("popstate", router);
document.addEventListener("DOMContentLoaded", () => {
console.debug(`[DOMContentLoaded] LocalStorage=${'localStorage' in window} Caches=${'caches' in window}`)
document.body.addEventListener("click", e => {
if (e.target instanceof Element
&& e.target.hasAttribute('data-link')
&& e.target.hasAttribute('href')
) {
e.preventDefault();
const target = e.target.getAttribute('href')!
console.debug(`[data-link] ${target}`)
Tracker.pageview(target)
history.pushState(null, '', target);
router();
}
});
router();
});

93
src/app/Schemas.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { CollectionRegistry, SchemaRegistry } from '@mcschema/core'
import { DataModel } from '@mcschema/core'
import * as java15 from '@mcschema/java-1.15'
import * as java16 from '@mcschema/java-1.16'
import * as java17 from '@mcschema/java-1.17'
import config from '../config.json'
import { fetchData } from './DataFetcher'
export const VersionIds = ['1.15', '1.16', '1.17'] as const
export type VersionId = typeof VersionIds[number]
export type BlockStateRegistry = {
[block: string]: {
properties: {
[key: string]: string[],
},
default: {
[key: string]: string,
},
},
}
type VersionData = {
collections: CollectionRegistry,
schemas: SchemaRegistry,
blockStates: BlockStateRegistry,
}
const Versions: Record<string, VersionData> = {}
type ModelData = {
model: DataModel,
version: VersionId,
}
const Models: Record<string, ModelData> = {}
const versionGetter: {
[versionId in VersionId]: {
getCollections: () => CollectionRegistry,
getSchemas: (collections: CollectionRegistry) => SchemaRegistry,
}
} = {
1.15: java15,
1.16: java16,
1.17: java17,
}
async function getVersion(id: VersionId): Promise<VersionData> {
if (!Versions[id]) {
const collections = versionGetter[id].getCollections()
const blockStates: BlockStateRegistry = {}
await fetchData(id, collections, blockStates)
const schemas = versionGetter[id].getSchemas(collections)
Versions[id] = { collections, schemas, blockStates }
}
return Versions[id]
}
export async function getModel(version: VersionId, id: string): Promise<DataModel> {
if (!Models[id] || Models[id].version !== version) {
const versionData = await getVersion(version)
const schemaName = config.models.find(m => m.id === id)?.schema
if (!schemaName) {
throw new Error(`Cannot find model ${id}`)
}
const schema = versionData.schemas.get(schemaName)
const model = new DataModel(schema)
if (Models[id]) {
model.reset(Models[id].model.data, false)
} else {
model.validate(true)
model.history = [JSON.stringify(model.data)]
}
Models[id] = { model, version }
}
return Models[id].model
}
export async function getCollections(version: VersionId): Promise<CollectionRegistry> {
const versionData = await getVersion(version)
return versionData.collections
}
export async function getBlockStates(version: VersionId): Promise<BlockStateRegistry> {
const versionData = await getVersion(version)
return versionData.blockStates
}
export function checkVersion(versionId: string, minVersionId: string | undefined, maxVersionId?: string) {
const version = config.versions.findIndex(v => v.id === versionId)
const minVersion = minVersionId ? config.versions.findIndex(v => v.id === minVersionId) : 0
const maxVersion = maxVersionId ? config.versions.findIndex(v => v.id === maxVersionId) : config.versions.length - 1
return minVersion <= version && version <= maxVersion
}

View File

@@ -1,22 +0,0 @@
type FieldSetting = {
path?: string
name?: string
hidden?: boolean
}
export class Settings {
fields: FieldSetting[]
constructor(private local_storage: string) {
const settings = JSON.parse(localStorage.getItem(local_storage) ?? '{}')
if (!Array.isArray(settings.fields)) settings.fields = []
this.fields = settings.fields
this.save()
}
save() {
const settings = JSON.stringify({ fields: this.fields })
localStorage.setItem(this.local_storage, settings)
this.fields = [...this.fields.filter(v => v?.path), {}]
}
}

36
src/app/Store.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { VersionId } from './Schemas'
import { VersionIds } from './Schemas'
export namespace Store {
export const ID_LANGUAGE = 'language'
export const ID_THEME = 'theme'
export const ID_VERSION = 'schema_version'
export function getLanguage() {
return localStorage.getItem(ID_LANGUAGE) ?? 'en'
}
export function getTheme() {
return localStorage.getItem(ID_THEME) ?? 'dark'
}
export function getVersion(): VersionId {
const version = localStorage.getItem(ID_VERSION)
if (version && VersionIds.includes(version as VersionId)) {
return version as VersionId
}
return '1.17'
}
export function setLanguage(language: string | undefined) {
if (language) localStorage.setItem(ID_LANGUAGE, language)
}
export function setTheme(theme: string | undefined) {
if (theme) localStorage.setItem(ID_THEME, theme)
}
export function setVersion(version: VersionId | undefined) {
if (version) localStorage.setItem(ID_VERSION, version)
}
}

View File

@@ -1,32 +0,0 @@
const event = (category: string, action: string, label?: string) =>
ga('send', 'event', category, action, label)
const dimension = (index: number, value: string) =>
ga('set', `dimension${index}`, value);
export const Tracker = {
pageview: (target: string) => {
ga('set', 'page', target)
ga('send', 'pageview')
},
setTheme: (theme: string) => event('Generator', 'set-theme', theme),
setVersion: (version: string) => event('Generator', 'set-version', version),
setPreview: (name: string) => event('Preview', 'set-preview', name),
setLanguage: (language: string) => event('Generator', 'set-language', language),
reset: () => event('Generator', 'reset'),
undo: (hotkey = false) => event('Generator', 'undo', hotkey ? 'Hotkey' : 'Menu'),
redo: (hotkey = false) => event('Generator', 'redo', hotkey ? 'Hotkey' : 'Menu'),
copy: () => event('JsonOutput', 'copy'),
download: () => event('JsonOutput', 'download'),
share: () => event('JsonOutput', 'share'),
toggleErrors: (visible: boolean) => event('Errors', 'toggle', visible ? 'visible' : 'hidden'),
hidePreview: () => event('Preview', 'hide-preview'),
toggleMinimize: (minimized: boolean) => event('Generator', 'toggle-minimize', minimized ? 'minimized' : 'unminimized'),
loadPreset: (preset: string) => event('Generator', 'load-preset', preset),
dimTheme: (theme: string) => dimension(1, theme),
dimVersion: (version: string) => dimension(3, version),
dimLanguage: (language: string) => dimension(4, language),
dimPreview: (preview: string) => dimension(5, preview),
}

View File

@@ -1,54 +1,58 @@
const dec2hex = (dec: number) => ('0' + dec.toString(16)).substr(-2)
export function hexId(length = 12) {
var arr = new Uint8Array(length / 2)
window.crypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
var arr = new Uint8Array(length / 2)
window.crypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
}
export function htmlEncode(str: string) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\//g, '&#x2F;')
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\//g, '&#x2F;')
}
export function hashString(s: string) {
let h = 0
for(let i = 0; i < s.length; i++)
h = Math.imul(31, h) + s.charCodeAt(i) | 0
return h
let h = 0
for(let i = 0; i < s.length; i++)
h = Math.imul(31, h) + s.charCodeAt(i) | 0
return h
}
export function cleanUrl(url: string) {
return `/${url}/`.replaceAll('//', '/')
}
export function stringToColor(str: string): [number, number, number] {
const h = Math.abs(hashString(str))
return [h % 256, (h >> 8) % 256, (h >> 16) % 256]
const h = Math.abs(hashString(str))
return [h % 256, (h >> 8) % 256, (h >> 16) % 256]
}
export function clamp(a: number, b: number, c: number) {
return Math.max(a, Math.min(b, c))
return Math.max(a, Math.min(b, c))
}
export function clampedLerp(a: number, b: number, c: number): number {
if (c < 0) {
return a;
} else if (c > 1) {
return b
} else {
return lerp(c, a, b)
}
if (c < 0) {
return a
} else if (c > 1) {
return b
} else {
return lerp(c, a, b)
}
}
export function lerp(a: number, b: number, c: number): number {
return b + a * (c - b);
return b + a * (c - b)
}
export function lerp2(a: number, b: number, c: number, d: number, e: number, f: number): number {
return lerp(b, lerp(a, c, d), lerp(a, e, f));
return lerp(b, lerp(a, c, d), lerp(a, e, f))
}
export function lerp3(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) {
return lerp(c, lerp2(a, b, d, e, f, g), lerp2(a, b, h, i, j, k))
return lerp(c, lerp2(a, b, d, e, f, g), lerp2(a, b, h, i, j, k))
}
export function smoothstep(x: number): number {
return x * x * x * (x * (x * 6 - 15) + 10);
return x * x * x * (x * (x * 6 - 15) + 10)
}

16
src/app/components/Ad.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect } from 'preact/hooks'
declare const ethicalads: any
type AdProps = {
type: 'text' | 'image',
id: string,
}
export function Ad({ type, id }: AdProps) {
useEffect(() => {
document.getElementById('ad-placeholder')?.remove()
ethicalads?.load()
}, [])
return <div data-ea-publisher="misode-github-io" data-ea-type={type} class="ad dark flat" id={id}></div>
}

View File

@@ -0,0 +1,15 @@
import { Octicon } from '.'
type BtnProps = {
icon?: keyof typeof Octicon,
label?: string,
active?: boolean,
class?: string,
onClick?: (event: MouseEvent) => unknown,
}
export function Btn({ icon, label, active, class: class_, onClick }: BtnProps) {
return <div class={`btn${active ? ' active' : ''}${class_ ? ` ${class_}` : ''}`} onClick={onClick}>
{icon && Octicon[icon]}
{label && <span>{label}</span>}
</div>
}

View File

@@ -0,0 +1,23 @@
import { Octicon } from '.'
type BtnInputProps = {
icon?: keyof typeof Octicon,
label?: string,
large?: boolean,
type?: 'number' | 'text',
value?: string,
onChange?: (value: string) => unknown,
}
export function BtnInput({ icon, label, large, type, value, onChange }: BtnInputProps) {
const onKeyUp = onChange === undefined ? () => {} : (e: any) => {
const value = (e.target as HTMLInputElement).value
if (type !== 'number' || (!value.endsWith('.') && !isNaN(Number(value)))) {
onChange?.(value)
}
}
return <div class={`btn btn-input ${large ? 'large-input' : ''}`} onClick={e => e.stopPropagation()}>
{icon && Octicon[icon]}
{label && <span>{label}</span>}
<input type="text" value={value} onKeyUp={onKeyUp} />
</div>
}

View File

@@ -0,0 +1,34 @@
import type { ComponentChildren } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import type { Octicon } from '.'
import { Btn } from '.'
type BtnMenuProps = {
icon?: keyof typeof Octicon,
label?: string,
relative?: boolean,
children: ComponentChildren,
}
export function BtnMenu({ icon, label, relative, children }: BtnMenuProps) {
const [active, setActive] = useState(false)
const hider = () => {
setActive(false)
}
useEffect(() => {
if (active) {
document.body.addEventListener('click', hider)
}
return () => {
document.body.removeEventListener('click', hider)
}
}, [active])
return <div class={`btn-menu${relative === false ? ' no-relative' : ''}`}>
<Btn icon={icon} label={label} onClick={() => setActive(true)} />
{active && <div class="btn-group">
{children}
</div>}
</div>
}

View File

@@ -1,21 +0,0 @@
import { Property } from '../state/Property';
import { View } from '../views/View';
import { Octicon } from './Octicon';
export const Dropdown = (view: View, icon: keyof typeof Octicon, entries: [string, string][], state: Property<string>, watcher?: (value: string) => void) => {
const dropdown = view.register(el => {
el.addEventListener('change', () => {
state.set((el as HTMLSelectElement).value)
})
state.watchRun(v => (el as HTMLSelectElement).value = v, 'dropdown')
})
return `
<div class="dropdown">
<select data-id="${dropdown}">
${entries.map(e => `
<option value=${e[0]}>${e[1]}</option>
`).join('')}
</select>
${Octicon[icon]}
</div>`
}

View File

@@ -1,53 +0,0 @@
import { DataModel } from '@mcschema/core';
import { App } from '../App';
import { locale } from '../Locales';
import { View } from '../views/View';
import { Octicon } from './Octicon';
import { Toggle } from './Toggle';
import { htmlEncode } from '../Utils'
import { Tracker } from '../Tracker';
export const Errors = (view: View, model: DataModel) => {
const getContent = () => {
if (App.jsonError.get()) {
return `<div class="error-list">
<div class="error">
${htmlEncode(App.jsonError.get()!)}
</div>
</div>
<div class="toggle" style="cursor: initial;">
${Octicon.issue_opened}
</div>`
}
if (model.errors.count() === 0) return ''
return `${App.errorsVisible.get() ? `
<div class="error-list">
${model.errors.getAll().map(e => `
<div class="error">
<span class="error-path">${e.path.toString()}</span>
<span>-</span>
<span class="error-message">${htmlEncode(locale(e.error, e.params))}</span>
</div>
`).join('')}
</div>
` : ''}
${Toggle(view, [[true, 'chevron_down'], [false, 'issue_opened']], App.errorsVisible, Tracker.toggleErrors)}`
}
const errors = view.register(el => {
model.addListener({
errors() {
view.mount(el, getContent(), false)
}
})
App.jsonError.watch(() => {
view.mount(el, getContent(), false)
})
App.errorsVisible.watch(() => {
view.mount(el, getContent(), false)
}, 'errors')
})
return `
<div class="errors" data-id="${errors}">
${getContent()}
</div>`
}

View File

@@ -1,58 +0,0 @@
import { App } from '../App';
import { View } from '../views/View';
import { Dropdown } from './Dropdown';
import { Octicon } from './Octicon';
import { Toggle } from './Toggle';
import { languages } from '../../config.json'
import { Tracker } from '../Tracker';
import { locale } from '../Locales';
export const Header = (view: View, title: string, homeLink = '/') => {
const panelTogglesId = view.register(el => {
const getPanelToggles = () => {
const panels = [['preview', 'play'], ['tree', 'note'], ['source', 'code']]
if (!panels.map(e => e[0]).includes(App.mobilePanel.get())) return ''
return panels
.filter(e => e[0] !== App.mobilePanel.get())
.filter(e => e[0] !== 'preview' || App.preview.get() !== null)
.map(e => `<div data-id="${view.onClick(() => App.mobilePanel.set(e[0]))}">
${Octicon[e[1] as keyof typeof Octicon]}
</div>`).join('')
}
App.mobilePanel.watchRun(() => {
view.mount(el, getPanelToggles(), false)
})
App.preview.watchRun((value, oldValue) => {
if (value === null && App.mobilePanel.get() === 'preview') {
App.mobilePanel.set('tree')
}
if (value === null || oldValue === null) {
view.mount(el, getPanelToggles(), false)
}
})
})
return `<header>
<div class="header-title">
<a data-link href="${homeLink}" class="home-link" aria-label="${locale('home')}">${Octicon.three_bars}</a>
<h2>${title}</h2>
</div>
<nav>
<div class="panel-toggles" data-id="${panelTogglesId}"></div>
<ul>
<li>${Dropdown(view, 'globe', languages.map(l => [l.code, l.name]), App.language, Tracker.setLanguage)}</li>
<li>${Toggle(view, [['dark', 'sun'], ['light', 'moon']], App.theme, Tracker.setTheme)}</li>
<li>
<a data-link href="/settings/fields/" title="${locale('settings')}">
${Octicon.gear}
</a>
</li>
<li class="dimmed">
<a href="https://github.com/misode/misode.github.io" target="_blank" rel="noreferrer" title="${locale('github')}">
${Octicon.mark_github}
</a>
</li>
</ul>
</nav>
</header>`
}

View File

@@ -0,0 +1,55 @@
import { getCurrentUrl, Link } from 'preact-router'
import { Btn, BtnMenu, Octicon } from '.'
import config from '../../config.json'
import { locale } from '../Locales'
const Themes: Record<string, keyof typeof Octicon> = {
system: 'device_desktop',
dark: 'moon',
light: 'sun',
}
type HeaderProps = {
lang: string,
title: string,
theme: string,
changeTheme: (theme: string) => unknown,
language: string,
changeLanguage: (language: string) => unknown,
}
export function Header({ lang, title, theme, changeTheme, language, changeLanguage }: HeaderProps) {
const loc = locale.bind(null, lang)
return <header>
<div class="header-title">
<Link class="home-link" href={getCurrentUrl().match(/^\/worldgen\/.+/) ? '/worldgen/' : '/'}>
{Octicon.three_bars}
</Link>
<h2>{title}</h2>
</div>
<nav>
<ul>
<li>
<BtnMenu icon="globe">
{config.languages.map(({ code, name }) =>
<Btn label={name} active={code === language}
onClick={() => changeLanguage(code)} />
)}
</BtnMenu>
</li>
<li>
<BtnMenu icon={Themes[theme]}>
{Object.entries(Themes).map(([th, icon]) =>
<Btn icon={icon} label={loc(`theme.${th}`)} active={th === theme}
onClick={() => changeTheme(th)} />
)}
</BtnMenu>
</li>
<li class="dimmed">
<a href="https://github.com/misode/misode.github.io" target="_blank" rel="noreferrer" title={loc('github')}>
{Octicon.mark_github}
</a>
</li>
</ul>
</nav>
</header>
}

View File

@@ -1,38 +0,0 @@
export const Octicon = {
archive: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 2.5a.25.25 0 00-.25.25v1.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-1.5a.25.25 0 00-.25-.25H1.75zM0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0114.25 6H1.75A1.75 1.75 0 010 4.25v-1.5zM1.75 7a.75.75 0 01.75.75v5.5c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25v-5.5a.75.75 0 111.5 0v5.5A1.75 1.75 0 0113.25 15H2.75A1.75 1.75 0 011 13.25v-5.5A.75.75 0 011.75 7zm4.5 1a.75.75 0 000 1.5h3.5a.75.75 0 100-1.5h-3.5z"></path></svg>',
arrow_both: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 1.06L2.56 7h10.88l-2.22-2.22a.75.75 0 011.06-1.06l3.5 3.5a.75.75 0 010 1.06l-3.5 3.5a.75.75 0 11-1.06-1.06l2.22-2.22H2.56l2.22 2.22a.75.75 0 11-1.06 1.06l-3.5-3.5a.75.75 0 010-1.06l3.5-3.5z"></path></svg>',
arrow_left: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.78 12.53a.75.75 0 01-1.06 0L2.47 8.28a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L4.81 7h7.44a.75.75 0 010 1.5H4.81l2.97 2.97a.75.75 0 010 1.06z"></path></svg>',
arrow_right: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z"></path></svg>',
chevron_down: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"></path></svg>',
chevron_right: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"></path></svg>',
clippy: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zm.75 3V2.5h3V4h-3zm-2.874-.467a.75.75 0 00-.752-1.298A1.75 1.75 0 002 3.75v9.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-9.5a1.75 1.75 0 00-.874-1.515.75.75 0 10-.752 1.298.25.25 0 01.126.217v9.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-9.5a.25.25 0 01.126-.217z"></path></svg>',
code: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 10-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 101.06 1.06l4.25-4.25a.75.75 0 000-1.06l-4.25-4.25z"></path></svg>',
dash: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2 8a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8z"></path></svg>',
download: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.47 10.78a.75.75 0 001.06 0l3.75-3.75a.75.75 0 00-1.06-1.06L8.75 8.44V1.75a.75.75 0 00-1.5 0v6.69L4.78 5.97a.75.75 0 00-1.06 1.06l3.75 3.75zM3.75 13a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z"></path></svg>',
eye: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"></path></svg>',
eye_closed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M.143 2.31a.75.75 0 011.047-.167l14.5 10.5a.75.75 0 11-.88 1.214l-2.248-1.628C11.346 13.19 9.792 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.832.88 9.577.43 8.9a1.618 1.618 0 010-1.797c.353-.533.995-1.42 1.868-2.305L.31 3.357A.75.75 0 01.143 2.31zm3.386 3.378a14.21 14.21 0 00-1.85 2.244.12.12 0 00-.022.068c0 .021.006.045.022.068.412.621 1.242 1.75 2.366 2.717C5.175 11.758 6.527 12.5 8 12.5c1.195 0 2.31-.488 3.29-1.191L9.063 9.695A2 2 0 016.058 7.52l-2.53-1.832zM8 3.5c-.516 0-1.017.09-1.499.251a.75.75 0 11-.473-1.423A6.23 6.23 0 018 2c1.981 0 3.67.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.619 1.619 0 010 1.798c-.11.166-.248.365-.41.587a.75.75 0 11-1.21-.887c.148-.201.272-.382.371-.53a.119.119 0 000-.137c-.412-.621-1.242-1.75-2.366-2.717C10.825 4.242 9.473 3.5 8 3.5z"></path></svg>',
fold: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M10.896 2H8.75V.75a.75.75 0 00-1.5 0V2H5.104a.25.25 0 00-.177.427l2.896 2.896a.25.25 0 00.354 0l2.896-2.896A.25.25 0 0010.896 2zM8.75 15.25a.75.75 0 01-1.5 0V14H5.104a.25.25 0 01-.177-.427l2.896-2.896a.25.25 0 01.354 0l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25zm-6.5-6.5a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM6 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 016 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM12 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 0112 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5z"></path></svg>',
gear: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.075.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.09-.3.071L3.27 2.776c-.644-.177-1.392.02-1.82.63a7.977 7.977 0 00-.704 1.217c-.315.675-.111 1.422.363 1.891l.815.806c.05.048.098.147.088.294a6.084 6.084 0 000 .772c.01.147-.038.246-.088.294l-.815.806c-.474.469-.678 1.216-.363 1.891.2.428.436.835.704 1.218.428.609 1.176.806 1.82.63l1.103-.303c.066-.019.176-.011.299.071.213.143.436.272.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.09.3-.071l1.102.302c.644.177 1.392-.02 1.82-.63.268-.382.505-.789.704-1.217.315-.675.111-1.422-.364-1.891l-.814-.806c-.05-.048-.098-.147-.088-.294a6.1 6.1 0 000-.772c-.01-.147.039-.246.088-.294l.814-.806c.475-.469.679-1.216.364-1.891a7.992 7.992 0 00-.704-1.218c-.428-.609-1.176-.806-1.82-.63l-1.103.303c-.066.019-.176.011-.299-.071a5.991 5.991 0 00-.668-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>',
globe: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.543 7.25h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.506 6.506 0 00-4.666 5.5zm2.733 1.5H1.543a6.506 6.506 0 004.666 5.5 11.13 11.13 0 01-.352-.552c-.715-1.192-1.437-2.874-1.581-4.948zm1.504 0h4.44a9.637 9.637 0 01-1.363 4.177c-.306.51-.612.919-.857 1.215a9.978 9.978 0 01-.857-1.215A9.637 9.637 0 015.78 8.75zm4.44-1.5H5.78a9.637 9.637 0 011.363-4.177c.306-.51.612-.919.857-1.215.245.296.55.705.857 1.215A9.638 9.638 0 0110.22 7.25zm1.504 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.506 6.506 0 004.666-5.5h-2.733zm2.733-1.5h-2.733c-.144-2.074-.866-3.756-1.58-4.948a11.738 11.738 0 00-.353-.552 6.506 6.506 0 014.666 5.5zM8 0a8 8 0 100 16A8 8 0 008 0z"></path></svg>',
history: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"></path></svg>',
info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"></path></svg>',
issue_opened: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"></path></svg>',
kebab_horizontal: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M8 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm13 0a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"></path></svg>',
link: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg>',
mark_github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>',
moon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M9.598 1.591a.75.75 0 01.785-.175 7 7 0 11-8.967 8.967.75.75 0 01.961-.96 5.5 5.5 0 007.046-7.046.75.75 0 01.175-.786zm1.616 1.945a7 7 0 01-7.678 7.678 5.5 5.5 0 107.678-7.678z"></path></svg>',
note: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z"></path></svg>',
package: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8.878.392a1.75 1.75 0 00-1.756 0l-5.25 3.045A1.75 1.75 0 001 4.951v6.098c0 .624.332 1.2.872 1.514l5.25 3.045a1.75 1.75 0 001.756 0l5.25-3.045c.54-.313.872-.89.872-1.514V4.951c0-.624-.332-1.2-.872-1.514L8.878.392zM7.875 1.69a.25.25 0 01.25 0l4.63 2.685L8 7.133 3.245 4.375l4.63-2.685zM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432L2.5 5.677zm6.25 8.271l4.625-2.683a.25.25 0 00.125-.216V5.677L8.75 8.432v5.516z"></path></svg>',
play: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>',
plus: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 2a.75.75 0 01.75.75v4.5h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5h-4.5a.75.75 0 010-1.5h4.5v-4.5A.75.75 0 018 2z"></path></svg>',
plus_circle: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"></path></svg>',
search: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z"></path></svg>',
square: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 5.75C4 4.784 4.784 4 5.75 4h4.5c.966 0 1.75.784 1.75 1.75v4.5A1.75 1.75 0 0110.25 12h-4.5A1.75 1.75 0 014 10.25v-4.5zm1.75-.25a.25.25 0 00-.25.25v4.5c0 .138.112.25.25.25h4.5a.25.25 0 00.25-.25v-4.5a.25.25 0 00-.25-.25h-4.5z"></path></svg>',
square_fill: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 4A1.75 1.75 0 004 5.75v4.5c0 .966.784 1.75 1.75 1.75h4.5A1.75 1.75 0 0012 10.25v-4.5A1.75 1.75 0 0010.25 4h-4.5z"></path></svg>',
sun: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 10.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM8 12a4 4 0 100-8 4 4 0 000 8zM8 0a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0V.75A.75.75 0 018 0zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 018 13zM2.343 2.343a.75.75 0 011.061 0l1.06 1.061a.75.75 0 01-1.06 1.06l-1.06-1.06a.75.75 0 010-1.06zm9.193 9.193a.75.75 0 011.06 0l1.061 1.06a.75.75 0 01-1.06 1.061l-1.061-1.06a.75.75 0 010-1.061zM16 8a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0116 8zM3 8a.75.75 0 01-.75.75H.75a.75.75 0 010-1.5h1.5A.75.75 0 013 8zm10.657-5.657a.75.75 0 010 1.061l-1.061 1.06a.75.75 0 11-1.06-1.06l1.06-1.06a.75.75 0 011.06 0zm-9.193 9.193a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0z"></path></svg>',
tag: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2.5 7.775V2.75a.25.25 0 01.25-.25h5.025a.25.25 0 01.177.073l6.25 6.25a.25.25 0 010 .354l-5.025 5.025a.25.25 0 01-.354 0l-6.25-6.25a.25.25 0 01-.073-.177zm-1.5 0V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 010 2.474l-5.026 5.026a1.75 1.75 0 01-2.474 0l-6.25-6.25A1.75 1.75 0 011 7.775zM6 5a1 1 0 100 2 1 1 0 000-2z"></path></svg>',
trashcan: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path></svg>',
three_bars: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path></svg>',
unfold: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M8.177.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V4H5.104a.25.25 0 01-.177-.427L7.823.677a.25.25 0 01.354 0zM7.25 10.75a.75.75 0 011.5 0V12h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 12H7.25v-1.25zm-5-2a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM6 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 016 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM12 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 0112 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5z"></path></svg>',
x: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path></svg>',
}

View File

@@ -0,0 +1,30 @@
export const Octicon = {
archive: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 2.5a.25.25 0 00-.25.25v1.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-1.5a.25.25 0 00-.25-.25H1.75zM0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0114.25 6H1.75A1.75 1.75 0 010 4.25v-1.5zM1.75 7a.75.75 0 01.75.75v5.5c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25v-5.5a.75.75 0 111.5 0v5.5A1.75 1.75 0 0113.25 15H2.75A1.75 1.75 0 011 13.25v-5.5A.75.75 0 011.75 7zm4.5 1a.75.75 0 000 1.5h3.5a.75.75 0 100-1.5h-3.5z"></path></svg>,
arrow_left: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.78 12.53a.75.75 0 01-1.06 0L2.47 8.28a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L4.81 7h7.44a.75.75 0 010 1.5H4.81l2.97 2.97a.75.75 0 010 1.06z"></path></svg>,
arrow_right: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8.22 2.97a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06l2.97-2.97H3.75a.75.75 0 010-1.5h7.44L8.22 4.03a.75.75 0 010-1.06z"></path></svg>,
chevron_right: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"></path></svg>,
clippy: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zm.75 3V2.5h3V4h-3zm-2.874-.467a.75.75 0 00-.752-1.298A1.75 1.75 0 002 3.75v9.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-9.5a1.75 1.75 0 00-.874-1.515.75.75 0 10-.752 1.298.25.25 0 01.126.217v9.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-9.5a.25.25 0 01.126-.217z"></path></svg>,
code: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4.72 3.22a.75.75 0 011.06 1.06L2.06 8l3.72 3.72a.75.75 0 11-1.06 1.06L.47 8.53a.75.75 0 010-1.06l4.25-4.25zm6.56 0a.75.75 0 10-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 101.06 1.06l4.25-4.25a.75.75 0 000-1.06l-4.25-4.25z"></path></svg>,
dash: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2 8a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8z"></path></svg>,
device_desktop: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 2.5h12.5a.25.25 0 01.25.25v7.5a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25v-7.5a.25.25 0 01.25-.25zM14.25 1H1.75A1.75 1.75 0 000 2.75v7.5C0 11.216.784 12 1.75 12h3.727c-.1 1.041-.52 1.872-1.292 2.757A.75.75 0 004.75 16h6.5a.75.75 0 00.565-1.243c-.772-.885-1.193-1.716-1.292-2.757h3.727A1.75 1.75 0 0016 10.25v-7.5A1.75 1.75 0 0014.25 1zM9.018 12H6.982a5.72 5.72 0 01-.765 2.5h3.566a5.72 5.72 0 01-.765-2.5z"></path></svg>,
download: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.47 10.78a.75.75 0 001.06 0l3.75-3.75a.75.75 0 00-1.06-1.06L8.75 8.44V1.75a.75.75 0 00-1.5 0v6.69L4.78 5.97a.75.75 0 00-1.06 1.06l3.75 3.75zM3.75 13a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z"></path></svg>,
eye: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"></path></svg>,
eye_closed: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M.143 2.31a.75.75 0 011.047-.167l14.5 10.5a.75.75 0 11-.88 1.214l-2.248-1.628C11.346 13.19 9.792 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.832.88 9.577.43 8.9a1.618 1.618 0 010-1.797c.353-.533.995-1.42 1.868-2.305L.31 3.357A.75.75 0 01.143 2.31zm3.386 3.378a14.21 14.21 0 00-1.85 2.244.12.12 0 00-.022.068c0 .021.006.045.022.068.412.621 1.242 1.75 2.366 2.717C5.175 11.758 6.527 12.5 8 12.5c1.195 0 2.31-.488 3.29-1.191L9.063 9.695A2 2 0 016.058 7.52l-2.53-1.832zM8 3.5c-.516 0-1.017.09-1.499.251a.75.75 0 11-.473-1.423A6.23 6.23 0 018 2c1.981 0 3.67.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.619 1.619 0 010 1.798c-.11.166-.248.365-.41.587a.75.75 0 11-1.21-.887c.148-.201.272-.382.371-.53a.119.119 0 000-.137c-.412-.621-1.242-1.75-2.366-2.717C10.825 4.242 9.473 3.5 8 3.5z"></path></svg>,
gear: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.075.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.09-.3.071L3.27 2.776c-.644-.177-1.392.02-1.82.63a7.977 7.977 0 00-.704 1.217c-.315.675-.111 1.422.363 1.891l.815.806c.05.048.098.147.088.294a6.084 6.084 0 000 .772c.01.147-.038.246-.088.294l-.815.806c-.474.469-.678 1.216-.363 1.891.2.428.436.835.704 1.218.428.609 1.176.806 1.82.63l1.103-.303c.066-.019.176-.011.299.071.213.143.436.272.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.09.3-.071l1.102.302c.644.177 1.392-.02 1.82-.63.268-.382.505-.789.704-1.217.315-.675.111-1.422-.364-1.891l-.814-.806c-.05-.048-.098-.147-.088-.294a6.1 6.1 0 000-.772c-.01-.147.039-.246.088-.294l.814-.806c.475-.469.679-1.216.364-1.891a7.992 7.992 0 00-.704-1.218c-.428-.609-1.176-.806-1.82-.63l-1.103.303c-.066.019-.176.011-.299-.071a5.991 5.991 0 00-.668-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>,
globe: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.543 7.25h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.506 6.506 0 00-4.666 5.5zm2.733 1.5H1.543a6.506 6.506 0 004.666 5.5 11.13 11.13 0 01-.352-.552c-.715-1.192-1.437-2.874-1.581-4.948zm1.504 0h4.44a9.637 9.637 0 01-1.363 4.177c-.306.51-.612.919-.857 1.215a9.978 9.978 0 01-.857-1.215A9.637 9.637 0 015.78 8.75zm4.44-1.5H5.78a9.637 9.637 0 011.363-4.177c.306-.51.612-.919.857-1.215.245.296.55.705.857 1.215A9.638 9.638 0 0110.22 7.25zm1.504 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.506 6.506 0 004.666-5.5h-2.733zm2.733-1.5h-2.733c-.144-2.074-.866-3.756-1.58-4.948a11.738 11.738 0 00-.353-.552 6.506 6.506 0 014.666 5.5zM8 0a8 8 0 100 16A8 8 0 008 0z"></path></svg>,
history: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"></path></svg>,
kebab_horizontal: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M8 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM1.5 9a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm13 0a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"></path></svg>,
link: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z"></path></svg>,
mark_github: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>,
moon: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M9.598 1.591a.75.75 0 01.785-.175 7 7 0 11-8.967 8.967.75.75 0 01.961-.96 5.5 5.5 0 007.046-7.046.75.75 0 01.175-.786zm1.616 1.945a7 7 0 01-7.678 7.678 5.5 5.5 0 107.678-7.678z"></path></svg>,
play: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>,
plus: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 2a.75.75 0 01.75.75v4.5h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5h-4.5a.75.75 0 010-1.5h4.5v-4.5A.75.75 0 018 2z"></path></svg>,
search: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z"></path></svg>,
sun: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 10.5a2.5 2.5 0 100-5 2.5 2.5 0 000 5zM8 12a4 4 0 100-8 4 4 0 000 8zM8 0a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0V.75A.75.75 0 018 0zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 018 13zM2.343 2.343a.75.75 0 011.061 0l1.06 1.061a.75.75 0 01-1.06 1.06l-1.06-1.06a.75.75 0 010-1.06zm9.193 9.193a.75.75 0 011.06 0l1.061 1.06a.75.75 0 01-1.06 1.061l-1.061-1.06a.75.75 0 010-1.061zM16 8a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0116 8zM3 8a.75.75 0 01-.75.75H.75a.75.75 0 010-1.5h1.5A.75.75 0 013 8zm10.657-5.657a.75.75 0 010 1.061l-1.061 1.06a.75.75 0 11-1.06-1.06l1.06-1.06a.75.75 0 011.06 0zm-9.193 9.193a.75.75 0 010 1.06l-1.06 1.061a.75.75 0 11-1.061-1.06l1.06-1.061a.75.75 0 011.061 0z"></path></svg>,
sync: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 11-1.49.178A5.501 5.501 0 008 2.5zM1.705 8.005a.75.75 0 01.834.656 5.501 5.501 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834z"></path></svg>,
tag: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2.5 7.775V2.75a.25.25 0 01.25-.25h5.025a.25.25 0 01.177.073l6.25 6.25a.25.25 0 010 .354l-5.025 5.025a.25.25 0 01-.354 0l-6.25-6.25a.25.25 0 01-.073-.177zm-1.5 0V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 010 2.474l-5.026 5.026a1.75 1.75 0 01-2.474 0l-6.25-6.25A1.75 1.75 0 011 7.775zM6 5a1 1 0 100 2 1 1 0 000-2z"></path></svg>,
three_bars: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path></svg>,
unfold: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path d="M8.177.677l2.896 2.896a.25.25 0 01-.177.427H8.75v1.25a.75.75 0 01-1.5 0V4H5.104a.25.25 0 01-.177-.427L7.823.677a.25.25 0 01.354 0zM7.25 10.75a.75.75 0 011.5 0V12h2.146a.25.25 0 01.177.427l-2.896 2.896a.25.25 0 01-.354 0l-2.896-2.896A.25.25 0 015.104 12H7.25v-1.25zm-5-2a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM6 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 016 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5zM12 8a.75.75 0 01-.75.75h-.5a.75.75 0 010-1.5h.5A.75.75 0 0112 8zm2.25.75a.75.75 0 000-1.5h-.5a.75.75 0 000 1.5h.5z"></path></svg>,
upload: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8.53 1.22a.75.75 0 00-1.06 0L3.72 4.97a.75.75 0 001.06 1.06l2.47-2.47v6.69a.75.75 0 001.5 0V3.56l2.47 2.47a.75.75 0 101.06-1.06L8.53 1.22zM3.75 13a.75.75 0 000 1.5h8.5a.75.75 0 000-1.5h-8.5z"></path></svg>,
x_circle: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.404 12.596a6.5 6.5 0 119.192-9.192 6.5 6.5 0 01-9.192 9.192zM2.344 2.343a8 8 0 1011.313 11.314A8 8 0 002.343 2.343zM6.03 4.97a.75.75 0 00-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 101.06 1.06L8 9.06l1.97 1.97a.75.75 0 101.06-1.06L9.06 8l1.97-1.97a.75.75 0 10-1.06-1.06L8 6.94 6.03 4.97z"></path></svg>,
}

View File

@@ -0,0 +1,70 @@
import type { DataModel } from '@mcschema/core'
import { Path } from '@mcschema/core'
import type { FunctionalComponent } from 'preact'
import { useState } from 'preact/hooks'
import { useModel } from '../hooks'
import type { VersionId } from '../Schemas'
import { BiomeSourcePreview, DecoratorPreview, NoiseSettingsPreview } from './previews'
export const HasPreview = ['dimension', 'worldgen/noise-settings', 'worldgen/feature']
export const Previews: {
id: string,
generator: string,
path: Path,
predicate: (model: DataModel) => boolean,
preview: FunctionalComponent<{
lang: string,
model: DataModel,
data: any,
version: VersionId,
shown: boolean,
}>,
}[] = [
{
id: 'biome-noise',
generator: 'dimension',
path: new Path(['generator', 'biome_source']),
predicate: model => model.get(new Path(['generator', 'type'])).endsWith('noise'),
preview: BiomeSourcePreview,
},
{
id: 'noise-settings',
generator: 'worldgen/noise-settings',
path: new Path(['noise']),
predicate: () => true,
preview: NoiseSettingsPreview,
},
{
id: 'decorator',
generator: 'worldgen/feature',
path: new Path([]),
predicate: () => true,
preview: DecoratorPreview,
},
]
type PreviewProps = {
lang: string,
model: DataModel | null,
version: VersionId,
id: string,
shown: boolean,
}
export function PreviewPanel({ lang, model, version, id, shown }: PreviewProps) {
const [, setCount] = useState(0)
useModel(model, () => {
setCount(count => count + 1)
})
return <>
{Previews.filter(p => p.generator === id).map(p => {
const data = model?.get(p.path)
if (!model || data === undefined || !p.predicate(model)) {
return <></>
}
return p.preview({ lang, model: model!, data, version, shown })
})}
</>
}

View File

@@ -0,0 +1,62 @@
import type { DataModel } from '@mcschema/core'
import { ModelPath } from '@mcschema/core'
import { useEffect, useRef } from 'preact/hooks'
import { useModel } from '../hooks'
import { locale } from '../Locales'
import { transformOutput } from '../schema/transformOutput'
type SourcePanelProps = {
lang: string,
name: string,
model: DataModel | null,
doCopy?: number,
doDownload?: number,
doImport?: number,
}
export function SourcePanel({ lang, name, model, doCopy, doDownload, doImport }: SourcePanelProps) {
const loc = locale.bind(null, lang)
const source = useRef<HTMLTextAreaElement>(null)
const download = useRef<HTMLAnchorElement>(null)
useModel(model, model => {
const data = model.schema.hook(transformOutput, new ModelPath(model), model.data)
source.current.value = JSON.stringify(data, null, 2) + '\n'
})
const onImport = () => {
try {
const data = JSON.parse(source.current.value)
model?.reset(data, false)
} catch (e) {
// TODO
}
}
useEffect(() => {
if (doCopy && source.current) {
source.current.select()
document.execCommand('copy')
}
}, [doCopy])
useEffect(() => {
if (doDownload && source.current && download.current) {
const content = encodeURIComponent(source.current.value)
download.current.setAttribute('href', `data:text/json;charset=utf-8,${content}`)
download.current.setAttribute('download', `${name}.json`)
download.current.click()
}
}, [doDownload])
useEffect(() => {
if (doImport && source.current) {
source.current.value = ''
source.current.select()
}
}, [doImport])
return <>
<textarea ref={source} class="source" onChange={onImport} spellcheck={false} autocorrect="off" placeholder={loc('source_placeholder')}></textarea>
<a ref={download} style="display: none;"></a>
</>
}

View File

@@ -1,10 +0,0 @@
import Split from 'split.js'
import { View } from '../views/View';
export const SplitGroup = (view: View, options: Split.Options, entries: string[]) => `
<div class="split-group ${options.direction ?? 'horizontal'}" data-id=${view.register(el => {
Split([].slice.call(el.children), { snapOffset: 0, ...options })
})}>
${entries.join('')}
</div>
`

View File

@@ -1,15 +0,0 @@
import { Property } from '../state/Property';
import { View } from '../views/View';
import { Octicon } from './Octicon';
export const Toggle = <T>(view: View, entries: [T, keyof typeof Octicon][], state: Property<T>, watcher?: (value: T) => void) => {
const activeOcticon = () => Octicon[(entries.find(e => e[0] === state.get()) ?? entries[0])[1]]
const toggle = view.register(el => {
el.addEventListener('click', () => {
const i = entries.findIndex(e => e[0] === state.get())
state.set(entries[(i + 1) % entries.length][0])
})
state.watch(_ => el.innerHTML = activeOcticon(), 'toggle')
})
return `<div class="toggle" data-id="${toggle}">${activeOcticon()}</div>`
}

View File

@@ -0,0 +1,49 @@
import type { DataModel } from '@mcschema/core'
import { ModelPath } from '@mcschema/core'
import { useEffect, useRef } from 'preact/hooks'
import { useModel } from '../hooks'
import { locale } from '../Locales'
import { Mounter } from '../schema/Mounter'
import { renderHtml } from '../schema/renderHtml'
import type { VersionId } from '../Schemas'
type TreePanelProps = {
lang: string,
model: DataModel | null,
version: VersionId,
}
export function Tree({ lang, model, version }: TreePanelProps) {
const tree = useRef<HTMLDivElement>(null)
const redraw = useRef<Function>()
useEffect(() => {
redraw.current = () => {
if (!model) return
const mounter = new Mounter()
const props = { loc: locale.bind(null, lang), version, mounter }
const path = new ModelPath(model)
const rendered = model.schema.hook(renderHtml, path, model.data, props)
const category = model.schema.category(path)
const type = model.schema.type(path)
let html = rendered[2]
if (rendered[1]) {
html = `<div class="node ${type}-node" ${category ? `data-category="${category}"` : ''}>
<div class="node-header">${rendered[1]}</div>
<div class="node-body">${rendered[2]}</div>
</div>`
}
tree.current.innerHTML = html
mounter.mounted(tree.current)
}
})
useModel(model, () => {
redraw.current()
})
useEffect(() => {
redraw.current()
}, [lang])
return <div ref={tree} class="tree"></div>
}

View File

@@ -0,0 +1,9 @@
export * from './Ad'
export * from './Btn'
export * from './BtnInput'
export * from './BtnMenu'
export * from './Header'
export * from './Octicon'
export * from './PreviewPanel'
export * from './SourcePanel'
export * from './Tree'

View File

@@ -1,71 +0,0 @@
import { DataModel } from '@mcschema/core';
import { App } from '../../App';
import { Tracker } from '../../Tracker';
import { View } from '../../views/View';
import { Octicon } from '../Octicon';
export const PreviewPanel = (view: View, model: DataModel) => {
const panel = view.register(el => {
const canvas = el.querySelector('canvas')!
const redraw = () => {
const preview = App.preview.get()
if (preview && preview.path && preview.path.withModel(model).get()) {
const ctx = canvas.getContext('2d')!
const newState = preview.path.withModel(model).get()
preview.state = JSON.parse(JSON.stringify(newState))
const [width, height] = preview.getSize()
canvas.width = width
canvas.height = height
const img = ctx.createImageData(width, height)
preview.draw(model, img)
ctx.putImageData(img, 0, 0)
} else {
App.preview.set(null)
}
}
const updatePreview = () => {
redraw()
view.mount(el.querySelector('.panel-controls')!, `
${App.preview.get()?.menu(view, redraw) ?? ''}
<div class="btn" data-id="${view.onClick(() => {
Tracker.hidePreview(); App.preview.set(null)
})}">
${Octicon.x}
</div>`, false)
}
model.addListener({
invalidated: redraw
})
App.preview.watchRun((value) => {
if (value) {
value.redraw = redraw
updatePreview()
}
}, 'preview-panel')
let dragStart: number[] | undefined
(el as HTMLCanvasElement).addEventListener('mousedown', evt => {
dragStart = [evt.offsetX, evt.offsetY]
})
;(el as HTMLCanvasElement).addEventListener('mousemove', evt => {
if (dragStart === undefined) return
if (App.preview.get()?.onDrag) {
const [width, height] = App.preview.get()!.getSize()
const dx = (evt.offsetX - dragStart[0]) * width / canvas.clientWidth
const dy = (evt.offsetY - dragStart[1]) * height / canvas.clientHeight
if (!(dx === 0 && dy === 0)) {
App.preview.get()?.onDrag(dx, dy)
redraw()
}
}
dragStart = [evt.offsetX, evt.offsetY]
})
;(el as HTMLCanvasElement).addEventListener('mouseup', evt => {
dragStart = undefined
})
})
return `<div class="panel preview-panel" data-id="${panel}">
<div class="panel-controls"></div>
<canvas width="512" height="256">
</div>`
}

View File

@@ -1,82 +0,0 @@
import { DataModel, ModelPath, Path } from '@mcschema/core';
import { Tracker } from '../../Tracker';
import { transformOutput } from '../../hooks/transformOutput';
import { toggleMenu, View } from '../../views/View';
import { Octicon } from '../Octicon';
import { App } from '../../App';
export const SourcePanel = (view: View, model: DataModel) => {
const updateContent = (el: HTMLTextAreaElement) => {
const data = model.schema.hook(transformOutput, new ModelPath(model), model.data);
App.jsonOutput.set(JSON.stringify(data, null, 2))
el.value = App.jsonOutput.get()
}
const source = view.register(el => {
updateContent(el as HTMLTextAreaElement)
model.addListener({
invalidated() {
App.jsonError.set(null)
updateContent(el as HTMLTextAreaElement)
}
})
el.addEventListener('change', () => {
const rawSource = (el as HTMLTextAreaElement).value
try {
model.reset(JSON.parse(rawSource))
App.jsonError.set(null)
} catch (err) {
App.jsonError.set(err.message)
}
})
})
const copySource = (el: Element) => {
el.closest('.panel')?.getElementsByTagName('textarea')[0].select()
document.execCommand('copy');
Tracker.copy()
}
const downloadSource = (el: Element) => {
const fileContents = encodeURIComponent(App.jsonOutput.get() + '\n')
const downloadAnchor = el.lastElementChild as HTMLAnchorElement
downloadAnchor.setAttribute('href', 'data:text/json;charset=utf-8,' + fileContents)
downloadAnchor.setAttribute("download", "data.json")
downloadAnchor.click()
Tracker.download()
}
const shareSource = (el: Element) => {
const shareInput = el.closest('.panel-controls')?.querySelector('input')!
const data = btoa(JSON.stringify(JSON.parse(App.jsonOutput.get())))
const url = window.location.origin + window.location.pathname + '?q=' + data
shareInput.value = url
shareInput.style.display = 'inline-block'
document.body.addEventListener('click', evt => {
shareInput.style.display = 'none'
}, { capture: true, once: true })
shareInput.select()
document.execCommand('copy');
Tracker.share()
}
return `<div class="panel source-panel">
<div class="panel-controls">
<input style="display: none;">
<div class="btn" data-id="${view.onClick(copySource)}">
${Octicon.clippy}
<span data-i18n="copy"></span>
</div>
<div class="panel-menu">
<div class="btn" data-id="${view.onClick(toggleMenu)}">
${Octicon.kebab_horizontal}
</div>
<div class="panel-menu-list btn-group">
<div class="btn" data-id="${view.onClick(downloadSource)}">
${Octicon.download}<span data-i18n="download"></span>
<a style="diplay: none;"></a>
</div>
<div class="btn" data-id="${view.onClick(shareSource)}">
${Octicon.link}<span data-i18n="share"></span>
</div>
</div>
</div>
</div>
<textarea class="source" data-id="${source}" spellcheck="false" autocorrect="off" autocapitalize="off"></textarea>
</div>`
}

View File

@@ -1,148 +0,0 @@
import { DataModel, ModelPath, Path } from '@mcschema/core';
import { App, checkVersion, Previews } from '../../App';
import { Tracker } from '../../Tracker'
import { toggleMenu, View } from '../../views/View';
import { Octicon } from '../Octicon';
import { renderHtml } from '../../hooks/renderHtml';
import config from '../../../config.json'
import { BiomeNoisePreview } from '../../preview/BiomeNoisePreview';
import { fetchPreset } from '../../DataFetcher'
export const TreePanel = (view: View, model: DataModel) => {
const getContent = () => {
if (App.loaded.get()) {
const path = new ModelPath(model)
const rendered = model.schema.hook(renderHtml, path, model.data, view)
const category = model.schema.category(path)
if (rendered[1]) {
return `<div class="node ${model.schema.type(path)}-node" ${category ? `data-category="${category}"` : ''}>
<div class="node-header">${rendered[1]}</div>
<div class="node-body">${rendered[2]}</div>
</div>`
}
return rendered[2]
}
return '<div class="spinner"></div>'
}
const tree = view.register(el => {
App.loaded.watchRun((value) => {
if (!value) {
// If loading is taking more than 100 ms, show spinner
new Promise(r => setTimeout(r, 100)).then(() => {
if (!App.loaded.get()) {
view.mount(el, getContent(), false)
}
})
} else {
view.mount(el, getContent(), false)
}
})
App.treeMinimized.watch(() => {
view.mount(el, getContent(), false)
})
model.addListener({
invalidated() {
view.mount(el, getContent(), false)
}
})
;(Previews.biome_noise as BiomeNoisePreview).biomeColors.watch(() => {
view.mount(el, getContent(), false)
}, 'tree-panel')
})
const m = App.model.get()
const registry = (m?.category ? m?.category + '/' : '') + m?.schema
let presetList: Element
const presetListId = view.register(el => presetList = el)
const getPresets = (query?: string) => {
const terms = (query ?? '').trim().split(' ')
const results = (App.collections.get()?.get(registry) ?? [])
.map(r => r.slice(10))
.filter(e => terms.every(t => e.includes(t)))
return results.map(r => `<div class="btn" data-id="${view.onClick(async () => {
App.schemasLoaded.set(false)
const preset = await fetchPreset(config.versions.find(v => v.id === App.version.get())!, m?.path!, r)
model.reset(preset)
App.schemasLoaded.set(true)
Tracker.loadPreset(m?.path! + '/' + r)
})}">${r}</div>`).join('')
}
const presetControl = view.register(el => {
App.schemasLoaded.watch(v => {
if (!v) return
const enabled = (m?.path && checkVersion(App.version.get(), '1.16'))
el.classList.toggle('disabled', !enabled || (App.collections.get()?.get(registry) ?? []).length === 0)
if (enabled) {
view.mount(presetList, getPresets(), false)
}
}, 'tree-panel')
})
return `<div class="panel tree-panel">
<div class="panel-controls">
<div class="panel-menu no-relative" data-id="${presetControl}">
<div class="btn" data-id="${view.onClick(el => {
toggleMenu(el)
el.parentElement?.querySelector('input')?.select()
})}">
${Octicon.archive}<span data-i18n="presets"></span>
</div>
<div class="panel-menu-list btn-group">
<div class="btn input large-input">
${Octicon.search}<input data-id="${view.on('keyup', el => {
view.mount(presetList, getPresets((el as HTMLInputElement).value), false)
})}">
</div>
<div class="result-list" data-id="${presetListId}"></div>
</div>
</div>
<div class="panel-menu">
<div class="btn" data-id="${view.onClick(toggleMenu)}">
${Octicon.tag}
<span data-id="${view.register(el => App.version.watch(v => el.textContent = v, 'tree-controls'))}">
${App.version.get()}
</span>
</div>
<div class="panel-menu-list btn-group">
${config.versions
.filter(v => checkVersion(v.id, App.model.get()!.minVersion ?? '1.15'))
.reverse()
.map(v => `
<div class="btn" data-id="${view.onClick(() => {
Tracker.setVersion(v.id); App.version.set(v.id)
})}">
${v.id}
</div>
`).join('')}
</div>
</div>
<div class="panel-menu">
<div class="btn" data-id="${view.onClick(toggleMenu)}">
${Octicon.kebab_horizontal}
</div>
<div class="panel-menu-list btn-group">
<div class="btn" data-id="${view.onClick(() => {
Tracker.reset(); model.reset(model.schema.default())
})}">
${Octicon.history}<span data-i18n="reset"></span>
</div>
<div class="btn" data-id="${view.register(el => {
el.addEventListener('click', () => {
const value = !App.treeMinimized.get()
App.treeMinimized.set(value)
Tracker.toggleMinimize(value)
})
App.treeMinimized.watchRun(value => {
view.mount(el, `${Octicon[value ? 'unfold' : 'fold']}<span data-i18n="${value ? 'maximize' : 'minimize'}"></span>`, false)
})
})}"></div>
<div class="btn" data-id="${view.onClick(() => {Tracker.undo(); model.undo()})}">
${Octicon.arrow_left}<span data-i18n="undo"></span>
</div>
<div class="btn" data-id="${view.onClick(() => {Tracker.redo(); model.redo()})}">
${Octicon.arrow_right}<span data-i18n="redo"></span>
</div>
</div>
</div>
</div>
<div class="tree" data-id="${tree}"></div>
</div>`
}

View File

@@ -0,0 +1,88 @@
import type { DataModel } from '@mcschema/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { Btn } from '..'
import { useOnDrag, useOnHover } from '../../hooks'
import { biomeSource, getBiome } from '../../previews'
import { hexId } from '../../Utils'
type BiomeSourceProps = {
lang: string,
model: DataModel,
data: any,
shown: boolean,
}
export const BiomeSourcePreview = ({ data, shown }: BiomeSourceProps) => {
const [scale, setScale] = useState(2)
const [seed, setSeed] = useState(hexId())
const [focused, setFocused] = useState<string | undefined>(undefined)
const type: string = data.type?.replace(/^minecraft:/, '')
const canvas = useRef<HTMLCanvasElement>(null)
const offset = useRef<[number, number]>([0, 0])
const redrawTimeout = useRef(undefined)
const redraw = useRef<Function>()
const refocus = useRef<Function>()
useEffect(() => {
redraw.current = (res = 4) => {
if (type !== 'multi_noise') res = 1
const ctx = canvas.current.getContext('2d')!
canvas.current.width = 200 / res
canvas.current.height = 200 / res
const img = ctx.createImageData(canvas.current.width, canvas.current.height)
biomeSource(data, img, { biomeColors: {}, offset: offset.current, scale, seed, res })
ctx.putImageData(img, 0, 0)
if (res !== 1) {
clearTimeout(redrawTimeout.current)
redrawTimeout.current = setTimeout(() => redraw.current(1), 150) as any
}
}
refocus.current = (x: number, y: number) => {
const x2 = x * 200 / canvas.current.clientWidth
const y2 = y * 200 / canvas.current.clientHeight
const biome = getBiome(data, x2, y2, { biomeColors: {}, offset: offset.current, scale, seed, res: 1 })
setFocused(biome)
}
})
useOnDrag(canvas.current, (dx, dy) => {
const x = dx * canvas.current.width / canvas.current.clientWidth
const y = dy * canvas.current.height / canvas.current.clientHeight
offset.current = [offset.current[0] + x, offset.current[1] + y]
redraw.current()
})
useOnHover(canvas.current, (x, y) => {
if (x === undefined || y === undefined) {
setFocused(undefined)
} else {
refocus.current(x, y)
}
})
const state = JSON.stringify(data)
useEffect(() => {
if (shown) {
redraw.current()
}
}, [state, scale, seed, shown])
const changeScale = (newScale: number) => {
offset.current[0] *= scale / newScale
offset.current[1] *= scale / newScale
setScale(newScale)
}
return <>
<div class="controls">
{focused && <Btn label={focused} class="no-pointer" />}
{(type === 'multi_noise' || type === 'checkerboard') && <>
<Btn icon="dash" onClick={() => changeScale(scale * 1.5)} />
<Btn icon="plus" onClick={() => changeScale(scale / 1.5)} />
</>}
{type === 'multi_noise' &&
<Btn icon="sync" onClick={() => setSeed(hexId())} />}
</div>
<canvas ref={canvas} width="200" height="200"></canvas>
</>
}

View File

@@ -0,0 +1,48 @@
import type { DataModel } from '@mcschema/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { Btn } from '..'
import { decorator } from '../../previews'
import type { VersionId } from '../../Schemas'
import { hexId } from '../../Utils'
type DecoratorProps = {
lang: string,
model: DataModel,
data: any,
version: VersionId,
shown: boolean,
}
export const DecoratorPreview = ({ data, version, shown }: DecoratorProps) => {
const [scale, setScale] = useState(4)
const [seed, setSeed] = useState(hexId())
const canvas = useRef<HTMLCanvasElement>(null)
const redraw = useRef<Function>()
useEffect(() => {
redraw.current = () => {
const ctx = canvas.current.getContext('2d')!
canvas.current.width = scale * 16
canvas.current.height = scale * 16
const img = ctx.createImageData(canvas.current.width, canvas.current.height)
decorator(data, img, { seed, version, size: [scale * 16, 128, scale * 16] })
ctx.putImageData(img, 0, 0)
}
})
const state = JSON.stringify(data)
useEffect(() => {
if (shown) {
setTimeout(() => redraw.current())
}
}, [state, scale, seed, shown])
return <>
<div class="controls">
<Btn icon="dash" onClick={() => setScale(Math.min(16, scale + 1))} />
<Btn icon="plus" onClick={() => setScale(Math.max(1, scale - 1))} />
<Btn icon="sync" onClick={() => setSeed(hexId())} />
</div>
<canvas ref={canvas} width="64" height="64"></canvas>
</>
}

View File

@@ -0,0 +1,60 @@
import type { DataModel } from '@mcschema/core'
import { useEffect, useRef, useState } from 'preact/hooks'
import { Btn, BtnInput, BtnMenu } from '..'
import { useOnDrag } from '../../hooks'
import { locale } from '../../Locales'
import { noiseSettings } from '../../previews'
import { hexId } from '../../Utils'
type NoiseSettingsProps = {
lang: string,
model: DataModel,
data: any,
shown: boolean,
}
export const NoiseSettingsPreview = ({ lang, data, shown }: NoiseSettingsProps) => {
const loc = locale.bind(null, lang)
const [seed, setSeed] = useState(hexId())
const [biomeDepth, setBiomeDepth] = useState(0.1)
const [biomeScale, setBiomeScale] = useState(0.2)
const canvas = useRef<HTMLCanvasElement>(null)
const offset = useRef<number>(0)
const redraw = useRef<Function>()
useEffect(() => {
redraw.current = () => {
const ctx = canvas.current.getContext('2d')!
const size = data.height
canvas.current.width = size
canvas.current.height = size
const img = ctx.createImageData(canvas.current.width, canvas.current.height)
noiseSettings(data, img, { biomeDepth, biomeScale, offset: offset.current, width: size, seed })
ctx.putImageData(img, 0, 0)
}
})
useOnDrag(canvas.current, (dx) => {
const x = dx * canvas.current.width / canvas.current.clientWidth
offset.current = offset.current + x
redraw.current()
})
const state = JSON.stringify(data)
useEffect(() => {
if (shown) {
redraw.current()
}
}, [state, biomeDepth, biomeScale, seed, shown])
return <>
<div class="controls">
<BtnMenu icon="gear">
<BtnInput type="number" label={loc('preview.depth')} value={`${biomeDepth}`} onChange={v => setBiomeDepth(Number(v))} />
<BtnInput type="number" label={loc('preview.scale')} value={`${biomeScale}`} onChange={v => setBiomeScale(Number(v))} />
</BtnMenu>
<Btn icon="sync" onClick={() => setSeed(hexId())} />
</div>
<canvas ref={canvas} width="200" height={data.height}></canvas>
</>
}

View File

@@ -0,0 +1,3 @@
export * from './BiomeSourcePreview'
export * from './DecoratorPreview'
export * from './NoiseSettingsPreview'

View File

@@ -1,17 +0,0 @@
import { Hook } from '@mcschema/core'
import { getFilterKey } from './getFilterKey'
export const canFlatten: Hook<[], boolean> = {
base: () => false,
object({ node, getActiveFields }, path) {
const filterKey = path.modelArr.length === 0 ? null : node.hook(getFilterKey, path, path)
const visibleEntries = Object.entries(getActiveFields(path))
.filter(([k, v]) => filterKey !== k && v.enabled(path))
if (visibleEntries.length !== 1) return false
const nestedPath = path.push(visibleEntries[0][0])
if (visibleEntries[0][1].type(nestedPath) !== 'object') return false
return visibleEntries[0][1].hook(getFilterKey, nestedPath, nestedPath) === null
}
}

View File

@@ -1,42 +0,0 @@
import { Errors, Hook, relativePath } from '@mcschema/core'
import { App } from '../App'
import { getFilterKey } from './getFilterKey'
import { walk } from './walk'
export const customValidation: Hook<[any, Errors], void> = walk<[Errors]>({
base() {},
map({ config }, path, value) {
if (config.validation?.validator === 'block_state_map') {
const block = relativePath(path, config.validation.params.id).get()
const errors = path.getModel().errors
const requiredProps = (App.blockStateRegistry[block] ?? {}).properties ?? {}
const existingKeys = Object.keys(value ?? {})
Object.keys(requiredProps).forEach(p => {
if (!existingKeys.includes(p)) {
if (path.last() === 'Properties') {
errors.add(path, 'error.block_state.missing_property', p)
}
} else if (!requiredProps[p].includes(value[p])) {
errors.add(path.push(p), 'error.invalid_enum_option', value[p])
}
})
}
},
/*
object({ node, getActiveFields }, path, value) {
let activeFields = getActiveFields(path)
const filterKey = path.modelArr.length === 0 ? null : node.hook(getFilterKey, path, path)
const visibleKeys = Object.keys(activeFields)
.filter(k => filterKey !== k)
.filter(k => activeFields[k].enabled(path))
if (visibleKeys.length === 1 && activeFields[visibleKeys[0]].type(path.push(visibleKeys[0])) === 'object') {
if (activeFields[visibleKeys[0]].optional() && JSON.stringify(value[visibleKeys[0]]) === '{}') {
path.push(visibleKeys[0]).set(undefined)
}
}
}
*/
})

View File

@@ -1,18 +0,0 @@
import { Hook, ModelPath, relativePath } from '@mcschema/core'
export const getFilterKey: Hook<[ModelPath, number?], string | null> = {
base: () => null,
object({ filter, getActiveFields }, path, origin, depth = 0) {
if (depth > 2) return null
if (filter) {
const filtered = relativePath(path, filter)
if (filtered && filtered.pop().equals(origin)) return filtered.last() as string
}
const activeFields = getActiveFields(path)
for (const k of Object.keys(activeFields)) {
const filtered = activeFields[k].hook(this, path.push(k), origin, depth += 1)
if (filtered) return filtered
}
return null
}
}

3
src/app/hooks/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './useModel'
export * from './useOnDrag'
export * from './useOnHover'

View File

@@ -1,375 +0,0 @@
import { Hook, ModelPath, Path, StringHookParams, ValidationOption, EnumOption, INode, DataModel, MapNode, StringNode, relativePath } from '@mcschema/core'
import { locale, segmentedLocale } from '../Locales'
import { Mounter } from '../views/View'
import { hexId, htmlEncode } from '../Utils'
import { suffixInjector } from './suffixInjector'
import { Octicon } from '../components/Octicon'
import { App } from '../App'
import { getFilterKey } from './getFilterKey'
import { canFlatten } from './canFlatten'
/**
* Secondary model used to remember the keys of a map
*/
const keysModel = new DataModel(MapNode(
StringNode(),
StringNode()
), { historyMax: 0 })
/**
* Renders the node and handles events to update the model
* @returns string HTML representation of this node using the given data
*/
export const renderHtml: Hook<[any, Mounter], [string, string, string]> = {
base() {
return ['', '', '']
},
boolean({ node }, path, value, mounter) {
const onFalse = mounter.onClick(el => {
path.model.set(path, node.optional() && value === false ? undefined : false)
})
const onTrue = mounter.onClick(el => {
path.model.set(path, node.optional() && value === true ? undefined : true)
})
return ['', `<button${value === false ? ' class="selected"' : ' '}
data-id="${onFalse}">${htmlEncode(locale('false'))}</button>
<button${value === true ? ' class="selected"' : ' '}
data-id="${onTrue}">${htmlEncode(locale('true'))}</button>`, '']
},
choice({ choices, config, switchNode }, path, value, mounter) {
const choice = switchNode.activeCase(path, true)
const pathWithContext = (config?.context) ? new ModelPath(path.getModel(), new Path(path.getArray(), [config.context])) : path
const pathWithChoiceContext = config?.choiceContext ? new Path([], [config.choiceContext]) : config?.context ? new Path([], [config.context]) : path
const [prefix, suffix, body] = choice.node.hook(this, pathWithContext, value, mounter)
if (choices.length === 1) {
return [prefix, suffix, body]
}
const inputId = mounter.register(el => {
(el as HTMLSelectElement).value = choice.type
el.addEventListener('change', () => {
const c = choices.find(c => c.type === (el as HTMLSelectElement).value) ?? choice
path.model.set(path, c.change ? c.change(value) : c.node.default())
})
})
const inject = `<select data-id="${inputId}">
${choices.map(c => `<option value="${htmlEncode(c.type)}">
${htmlEncode(pathLocale(pathWithChoiceContext.push(c.type)))}
</option>`).join('')}
</select>`
return [prefix, inject + suffix, body]
},
list({ children }, path, value, mounter) {
const onAdd = mounter.onClick(el => {
if (!Array.isArray(value)) value = []
path.model.set(path, [children.default(), ...value])
})
const onAddBottom = mounter.onClick(el => {
if (!Array.isArray(value)) value = []
path.model.set(path, [...value, children.default()])
})
const suffix = `<button class="add" data-id="${onAdd}" aria-label="${locale('button.add')}">${Octicon.plus_circle}</button>`
let body = ''
if (Array.isArray(value)) {
body = value.map((childValue, index) => {
const removeId = mounter.onClick(el => path.model.set(path.push(index), undefined))
const childPath = path.push(index).contextPush('entry')
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, childValue, mounter)
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(childPath, mounter)}
${help(childPath, mounter)}
<button class="remove" data-id="${removeId}" aria-label="${locale('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(childPath, mounter)}>
${htmlEncode(pathLocale(path.contextPush('entry'), [`${index}`]))}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div></div>`
}).join('')
if (value.length > 2) {
body += `<div class="node-entry">
<div class="node node-header">
<button class="add" data-id="${onAddBottom}" aria-label="${locale('button.add')}">${Octicon.plus_circle}</button>
</div>
</div>`
}
}
return ['', suffix, body]
},
map({ keys, children, config }, path, value, mounter) {
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
const onAdd = mounter.onClick(el => {
const key = keyPath.get()
path.model.set(path.push(key), children.default())
})
let suffix = ''
const blockState = (config.validation?.validator === 'block_state_map' ? App.blockStateRegistry[relativePath(path, config.validation.params.id).get()] : null)
if (!blockState || blockState.properties) {
const keyRendered = (blockState
? StringNode(null!, { enum: Object.keys(blockState.properties ?? {}) })
: keys).hook(this, keyPath, keyPath.get() ?? '', mounter)
suffix = keyRendered[1] + `<button class="add" data-id="${onAdd}" aria-label="${locale('button.add')}">${Octicon.plus_circle}</button>`
}
let body = ''
if (typeof value === 'object' && value !== undefined) {
body = Object.keys(value)
.map(key => {
const removeId = mounter.onClick(el => path.model.set(path.push(key), undefined))
const childPath = path.modelPush(key)
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = (blockState
? StringNode(null!, blockState.properties && { enum: blockState.properties[key] })
: children).hook(this, childPath, value[key], mounter)
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(childPath, mounter)}
${help(childPath, mounter)}
<button class="remove" data-id="${removeId}" aria-label="${locale('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(childPath, mounter)}>
${htmlEncode(key)}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div></div>`
})
.join('')
}
return ['', suffix, body]
},
number({ integer, config }, path, value, mounter) {
const onChange = mounter.onChange(el => {
const value = (el as HTMLInputElement).value
let parsed = config?.color
? parseInt(value.slice(1), 16)
: integer ? parseInt(value) : parseFloat(value)
path.model.set(path, parsed)
})
if (config?.color) {
const hex = (value?.toString(16).padStart(6, '0') ?? '000000')
return ['', `<input type="color" data-id="${onChange}" value="#${hex}">`, '']
}
return ['', `<input data-id="${onChange}" value="${value ?? ''}">`, '']
},
object({ node, getActiveFields, getChildModelPath }, path, value, mounter) {
let prefix = ''
if (node.optional()) {
if (value === undefined) {
prefix = `<button class="collapse closed" data-id="${mounter.onClick(() => path.model.set(path, node.default()))}" aria-label="${locale('button.expand')}">${Octicon.plus_circle}</button>`
} else {
prefix = `<button class="collapse open" data-id="${mounter.onClick(() => path.model.set(path, undefined))}" aria-label="${locale('button.collapse')}">${Octicon.trashcan}</button>`
}
}
let suffix = ''
let body = ''
if (typeof value === 'object' && value !== undefined && (!(node.optional() && value === undefined))) {
const activeFields = getActiveFields(path)
const activeKeys = Object.keys(activeFields)
const filterKey = path.modelArr.length === 0 ? null : node.hook(getFilterKey, path, path)
if (filterKey && !(activeFields[filterKey].hidden && activeFields[filterKey].hidden())) {
prefix += error(path.push(filterKey), mounter)
prefix += help(path.push(filterKey), mounter)
suffix += activeFields[filterKey].hook(this, path.push(filterKey), value[filterKey], mounter)[1]
}
const visibleKeys = (App.treeMinimized.get()
? activeKeys.filter(k => value[k] !== undefined)
: activeKeys)
.filter(k => filterKey !== k)
.filter(k => activeFields[k].enabled(path))
if (false /* node.hook(canFlatten, path) */) {
const newValue = value[visibleKeys[0]] ?? {}
body = activeFields[visibleKeys[0]].hook(this, path.push(visibleKeys[0]), newValue, mounter)[2]
} else {
body = visibleKeys.map(k => {
const field = activeFields[k]
const childPath = getChildModelPath(path, k)
const context = childPath.getContext().join('.')
const fieldSettings = App.settings.fields.find(f => f?.path && context.endsWith(f.path))
if ((field.hidden && field.hidden()) || fieldSettings?.hidden) return ''
const category = field.category(childPath)
const [cPrefix, cSuffix, cBody] = field.hook(this, childPath, value[k], mounter)
if (k === 'Properties' && cSuffix === '') return ''
return `<div class="node ${field.type(childPath)}-node ${cBody ? '' : 'no-body'}" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(childPath, mounter)}
${help(childPath, mounter)}
${cPrefix}
<label ${contextMenu(childPath, mounter)}>
${htmlEncode(fieldSettings?.name ?? pathLocale(childPath))}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div>`
})
.join('')
}
}
suffix += node.hook(suffixInjector, path, mounter) || ''
return ['', prefix + suffix, body]
},
string(params, path, value, mounter) {
const inputId = mounter.register(el => {
(el as HTMLSelectElement).value = value ?? ''
el.addEventListener('change', evt => {
const newValue = (el as HTMLSelectElement).value
path.model.set(path, newValue.length === 0 ? undefined : newValue)
evt.stopPropagation()
})
})
const suffix = params.node.hook(suffixInjector, path, mounter) || ''
return ['', rawString(params, path, inputId) + suffix, '']
}
}
function isEnum(value?: ValidationOption | EnumOption): value is EnumOption {
return !!(value as any)?.enum
}
function isValidator(value?: ValidationOption | EnumOption): value is ValidationOption {
return !!(value as any)?.validator
}
function rawString({ node, getValues, config }: { node: INode } & StringHookParams, path: ModelPath, inputId?: string) {
const values = getValues()
if (isEnum(config) && !config.additional) {
const contextPath = typeof config.enum === 'string' ?
new Path(path.getArray(), [config.enum]) : path
return selectRaw(node, contextPath, values, inputId)
}
if (config && isValidator(config)
&& config.validator === 'resource'
&& typeof config.params.pool === 'string'
&& values.length > 0) {
const contextPath = new Path(path.getArray(), [config.params.pool])
if (segmentedLocale(contextPath.contextPush(values[0]).getContext())) {
return selectRaw(node, contextPath, values, inputId)
}
}
const datalistId = hexId()
return `<input data-id="${inputId}" ${values.length === 0 ? '' : `list="${datalistId}"`}>
${values.length === 0 ? '' :
`<datalist id="${datalistId}">
${values.map(v =>
`<option value="${htmlEncode(v)}">`
).join('')}
</datalist>`}`
}
function selectRaw(node: INode, contextPath: Path, values: string[], inputId?: string) {
return `<select data-id="${inputId}">
${node.optional() ? `<option value="">${htmlEncode(locale('unset'))}</option>` : ''}
${values.map(v => `<option value="${htmlEncode(v)}">
${htmlEncode(pathLocale(contextPath.contextPush(v)))}
</option>`).join('')}
</select>`
}
function hashString(str: string) {
var hash = 0, i, chr;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash;
}
function pathLocale(path: Path, params?: string[]): string {
return segmentedLocale(path.getContext(), params)
?? path.getContext()[path.getContext().length - 1] ?? ''
}
function error(p: ModelPath, mounter: Mounter) {
const errors = p.model.errors.get(p, true)
if (errors.length === 0) return ''
return popupIcon('node-error', 'issue_opened', htmlEncode(locale(errors[0].error, errors[0].params)), mounter)
}
function help(path: ModelPath, mounter: Mounter) {
const message = segmentedLocale(path.contextPush('help').getContext(), [], 6)
if (message === undefined) return ''
return popupIcon('node-help', 'info', htmlEncode(message), mounter)
}
const popupIcon = (type: string, icon: keyof typeof Octicon, popup: string, mounter: Mounter) => {
const onClick = mounter.onClick(el => {
el.getElementsByTagName('span')[0].classList.add('show')
document.body.addEventListener('click', () => {
el.getElementsByTagName('span')[0].classList.remove('show')
}, { capture: true, once: true })
})
return `<div class="node-icon ${type}" data-id="${onClick}">
<span class="icon-popup">${popup}</span>${Octicon[icon]}
</div>`
}
const contextMenu = (path: ModelPath, mounter: Mounter) => {
const id = mounter.register(el => {
const openMenu = () => {
const popup = document.createElement('div')
popup.classList.add('node-menu')
const helpMessage = segmentedLocale(path.contextPush('help').getContext(), [], 6)
if (helpMessage) popup.insertAdjacentHTML('beforeend', `<span class="menu-item help-item">${helpMessage}</span>`)
const context = path.getContext().join('.')
popup.insertAdjacentHTML('beforeend', `
<div class="menu-item">
<span class="btn">${Octicon.clippy}</span>
Context:&nbsp
<span class="menu-item-context">${context}</span>
</div>`)
popup.querySelector('.menu-item .btn')?.addEventListener('click', () => {
const inputEl = document.createElement('input')
inputEl.value = context
el.appendChild(inputEl)
inputEl.select()
document.execCommand('copy')
el.removeChild(inputEl)
})
el.appendChild(popup)
document.body.addEventListener('click', () => {
try {el.removeChild(popup)} catch (e) {}
}, { capture: true, once: true })
document.body.addEventListener('contextmenu', () => {
try {el.removeChild(popup)} catch (e) {}
}, { capture: true, once: true })
}
el.addEventListener('contextmenu', evt => {
openMenu()
evt.preventDefault()
})
let timer: any = null
el.addEventListener('touchstart', () => {
timer = setTimeout(() => {
openMenu()
timer = null
}, 800)
})
el.addEventListener('touchend', () => {
if (timer) {
clearTimeout(timer)
timer = null
}
})
})
return `data-id="${id}"`
}

View File

@@ -1,49 +0,0 @@
import { Hook, ModelPath, Path } from '@mcschema/core'
import { App, Previews } from '../App'
import { Octicon } from '../components/Octicon'
import { locale } from '../Locales'
import { Mounter } from '../views/View'
import { BiomeNoisePreview } from '../preview/BiomeNoisePreview'
import { Preview } from '../preview/Preview'
import { Tracker } from '../Tracker'
export const suffixInjector: Hook<[Mounter], string | void> = {
base() {},
choice({ switchNode }, path, mounter) {
return switchNode.hook(this, path, mounter)
},
object({}, path, mounter) {
if (Previews.biome_noise.active(path)) {
return setPreview(Previews.biome_noise, path, mounter)
}
if (Previews.noise_settings.active(path)) {
return setPreview(Previews.noise_settings, path, mounter)
}
if (Previews.decorator.active(path)) {
return setPreview(Previews.decorator, path, mounter)
}
},
string({}, path, mounter) {
if (path.endsWith(new Path(['biome']))
&& path.pop().pop().endsWith(new Path(['generator', 'biome_source', 'biomes']))) {
const biomePreview = Previews.biome_noise as BiomeNoisePreview
const biome = path.get()
const id = mounter.onChange(el => {
biomePreview.setBiomeColor(biome, (el as HTMLInputElement).value)
})
return `<input type="color" value="${biomePreview.getBiomeHex(biome)}" data-id=${id}></input>`
}
}
}
function setPreview(preview: Preview, path: ModelPath, mounter: Mounter) {
const id = mounter.onClick(() => {
Tracker.setPreview(preview.getName())
preview.path = path
App.preview.set(preview)
})
return `<button data-id=${id}>${locale('preview')} ${Octicon.play}</button>`
}

View File

@@ -1,41 +0,0 @@
import { Hook } from '@mcschema/core'
export const transformOutput: Hook<[any], any> = {
base({}, _, value) {
return value
},
choice({ switchNode }, path, value) {
return switchNode.hook(this, path, value)
},
list({ children }, path, value) {
if (!Array.isArray(value)) return value
return value.map((obj, index) =>
children.hook(this, path.push(index), obj)
)
},
map({ children }, path, value) {
if (value === undefined) return undefined
let res: any = {}
Object.keys(value).forEach(f =>
res[f] = children.hook(this, path.push(f), value[f])
)
return res;
},
object({ getActiveFields }, path, value) {
if (value === undefined || value === null || typeof value !== 'object') {
return value
}
let res: any = {}
const activeFields = getActiveFields(path)
Object.keys(activeFields)
.filter(k => activeFields[k].enabled(path))
.forEach(f => {
res[f] = activeFields[f].hook(this, path.push(f), value[f])
})
return res
}
}

20
src/app/hooks/useModel.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { DataModel } from '@mcschema/core'
import { useEffect } from 'preact/hooks'
export function useModel(model: DataModel | undefined | null, invalidated: (model: DataModel) => unknown) {
const listener = {
invalidated() {
if (model) {
invalidated(model)
}
},
}
useEffect(() => {
model?.addListener(listener)
listener.invalidated()
return () => {
model?.removeListener(listener)
}
}, [model])
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'preact/hooks'
export function useOnDrag(element: HTMLElement, drag: (dx: number, dy: number) => unknown) {
if (!element) return
const request = useRef<number>()
const dragStart = useRef<[number, number] | undefined>()
const pending = useRef<[number, number]>([0, 0])
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
dragStart.current = [e.offsetX, e.offsetY]
}
const onMouseMove = (e: MouseEvent) => {
if (dragStart.current === undefined) return
const dx = e.offsetX - dragStart.current[0]
const dy = e.offsetY - dragStart.current[1]
if (!(dx === 0 && dy === 0)) {
cancelAnimationFrame(request.current)
pending.current = [pending.current[0] + dx, pending.current[1] + dy]
request.current = requestAnimationFrame(() => {
drag(...pending.current)
pending.current = [0, 0]
})
}
dragStart.current = [e.offsetX, e.offsetY]
}
const onMouseUp = (_e: MouseEvent) => {
dragStart.current = undefined
}
element.addEventListener('mousedown', onMouseDown)
element.addEventListener('mousemove', onMouseMove)
document.body.addEventListener('mouseup', onMouseUp)
return () => {
element.removeEventListener('mousedown', onMouseDown)
element.removeEventListener('mousemove', onMouseMove)
document.body.removeEventListener('mouseup', onMouseUp)
}
}, [element])
}

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'preact/hooks'
export function useOnHover(element: HTMLElement, hover: (x: number | undefined, y: number | undefined) => unknown) {
if (!element) return
const onMouseMove = (e: MouseEvent) => {
hover(e.offsetX, e.offsetY)
}
const onMouseLeave = () => {
hover(undefined, undefined)
}
useEffect(() => {
element.addEventListener('mousemove', onMouseMove)
element.addEventListener('mouseleave', onMouseLeave)
return () => {
element.removeEventListener('mousemove', onMouseMove)
element.removeEventListener('mouseleave', onMouseLeave)
}
}, [element])
}

View File

@@ -1,39 +0,0 @@
import { Hook } from '@mcschema/core'
type Args = any[]
export const walk = <U extends Args> (hook: Hook<[any, ...U], void>): Hook<[any, ...U], void> => ({
...hook,
choice(params, path, value, ...args) {
(hook.choice ?? hook.base)(params, path, value, ...args)
params.switchNode.hook(this, path, value, ...args)
},
list(params, path, value, ...args) {
(hook.list ?? hook.base)(params, path, value, ...args)
if (!Array.isArray(value)) return
value.forEach((e, i) =>
params.children.hook(this, path.push(i), e, ...args)
)
},
map(params, path, value, ...args) {
(hook.map ?? hook.base)(params, path, value, ...args)
if (typeof value !== 'object') return
Object.keys(value).forEach(f =>
params.children.hook(this, path.push(f), value[f], ...args)
)
},
object(params, path, value, ...args) {
(hook.object ?? hook.base)(params, path, value, ...args)
if (value === null || typeof value !== 'object') return
const activeFields = params.getActiveFields(path)
Object.keys(activeFields)
.filter(f => activeFields[f].enabled(path))
.forEach(f => {
activeFields[f].hook(this, path.push(f), value[f], ...args)
})
}
})

View File

@@ -0,0 +1,17 @@
import { locale } from '../Locales'
type FieldSettingsProps = {
lang: string,
path?: string,
}
export function FieldSettings({ lang }: FieldSettingsProps) {
const loc = locale.bind(null, lang)
return <main>
<div class="settings">
<p>{loc('settings.fields.description')}</p>
<ul class="field-list">
</ul>
</div>
</main>
}

180
src/app/pages/Generator.tsx Normal file
View File

@@ -0,0 +1,180 @@
import type { DataModel } from '@mcschema/core'
import { useEffect, useState } from 'preact/hooks'
import config from '../../config.json'
import { Analytics } from '../Analytics'
import { Ad, Btn, BtnInput, BtnMenu, HasPreview, Octicon, PreviewPanel, SourcePanel, Tree } from '../components'
import { fetchPreset } from '../DataFetcher'
import { locale } from '../Locales'
import type { VersionId } from '../Schemas'
import { checkVersion, getCollections, getModel } from '../Schemas'
type GeneratorProps = {
lang: string,
changeTitle: (title: string, versions?: string[]) => unknown,
version: VersionId,
onChangeVersion: (version: VersionId) => unknown,
generator?: string,
path?: string,
category?: string,
}
export function Generator({ lang, changeTitle, version, onChangeVersion, category, generator }: GeneratorProps) {
const loc = locale.bind(null, lang)
const id = category ? `${category}/${generator}` : generator ?? ''
const modelConfig = config.models.find(m => m.id === id)
if (!modelConfig) {
return <div class="error">Not found</div>
}
const minVersion = config.models.find(m => m.id === id)?.minVersion ?? '1.15'
const allowedVersions = config.versions
.filter(v => checkVersion(v.id, minVersion))
.map(v => v.id as VersionId)
changeTitle(loc('title.generator', loc(id)), allowedVersions)
const [model, setModel] = useState<DataModel | null>(null)
useEffect(() => {
setModel(null)
getModel(version, id).then(m => setModel(m))
}, [version, category, generator])
const reset = () => {
Analytics.generatorEvent('reset')
model?.reset(model.schema.default(), true)
}
const undo = (e: MouseEvent) => {
e.stopPropagation()
Analytics.generatorEvent('undo', 'Menu')
model?.undo()
}
const redo = (e: MouseEvent) => {
e.stopPropagation()
Analytics.generatorEvent('redo', 'Menu')
model?.redo()
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'z') {
Analytics.generatorEvent('undo', 'Hotkey')
model?.undo()
} else if (e.ctrlKey && e.key === 'y') {
Analytics.generatorEvent('redo', 'Hotkey')
model?.redo()
}
}
useEffect(() => {
document.addEventListener('keyup', onKeyUp)
return () => {
document.removeEventListener('keyup', onKeyUp)
}
}, [model])
const [presetFilter, setPresetFilter] = useState('')
const [presetResults, setPresetResults] = useState<string[]>([])
const registry = (modelConfig.category ? modelConfig.category + '/' : '') + modelConfig.schema
useEffect(() => {
if (!modelConfig.path) return
getCollections(version).then(collections => {
const terms = (presetFilter ?? '').trim().split(' ')
const presets = collections.get(registry)
.map(p => p.slice(10))
.filter(p => terms.every(t => p.includes(t)))
if (presets) {
setPresetResults(presets)
}
})
}, [version, category, generator, presetFilter])
const loadPreset = (id: string) => {
Analytics.generatorEvent('load-preset', id)
fetchPreset(version, modelConfig.path!, id).then(preset => {
model?.reset(preset, false)
})
}
const [sourceShown, setSourceShown] = useState(window.innerWidth > 820)
const [doCopy, setCopy] = useState(0)
const [doDownload, setDownload] = useState(0)
const [doImport, setImport] = useState(0)
const copySource = () => {
Analytics.generatorEvent('copy')
setCopy(doCopy + 1)
}
const downloadSource = () => {
Analytics.generatorEvent('download')
setDownload(doDownload + 1)
}
const importSource = () => {
Analytics.generatorEvent('import')
setSourceShown(true)
setImport(doImport + 1)
}
const toggleSource = () => {
Analytics.generatorEvent('toggle-output', !sourceShown ? 'visible' : 'hidden')
setSourceShown(!sourceShown)
setCopy(0)
setDownload(0)
setImport(0)
}
const [previewShown, setPreviewShown] = useState(false)
const hasPreview = HasPreview.includes(id)
let actionsShown = 1
if (hasPreview) actionsShown += 1
if (sourceShown) actionsShown += 2
const togglePreview = () => {
Analytics.generatorEvent('toggle-preview', !previewShown ? 'visible' : 'hidden')
setPreviewShown(!previewShown)
}
return <>
<main class={previewShown ? 'has-preview' : ''}>
<Ad id="data-pack-generator" type="text" />
<div class="controls">
<Btn icon="upload" label={loc('import')} onClick={importSource} />
{modelConfig.path && <BtnMenu icon="archive" label={loc('presets')} relative={false}>
<BtnInput icon="search" large value={presetFilter} onChange={setPresetFilter} />
<div class="result-list">
{presetResults.map(preset => <Btn label={preset} onClick={() => loadPreset(preset)} />)}
</div>
{presetResults.length === 0 && <Btn label={loc('no_presets')}/>}
</BtnMenu>}
<BtnMenu icon="tag" label={version}>
{allowedVersions.reverse().map(v =>
<Btn label={v} active={v === version} onClick={() => onChangeVersion(v)} />
)}
</BtnMenu>
<BtnMenu icon="kebab_horizontal">
<Btn icon="history" label={loc('reset')} onClick={reset} />
<Btn icon="arrow_left" label={loc('undo')} onClick={undo} />
<Btn icon="arrow_right" label={loc('redo')} onClick={redo} />
</BtnMenu>
</div>
<Tree {...{lang, model, version}} />
</main>
<div class="popup-actions" style={`--offset: -${10 + actionsShown * 50}px;`}>
<div class={`popup-action action-preview${hasPreview ? ' shown' : ''}`} onClick={togglePreview}>
{previewShown ? Octicon.x_circle : Octicon.play}
</div>
<div class={`popup-action action-download${sourceShown ? ' shown' : ''}`} onClick={downloadSource}>
{Octicon.download}
</div>
<div class={`popup-action action-copy${sourceShown ? ' shown' : ''}`} onClick={copySource}>
{Octicon.clippy}
</div>
<div class={'popup-action action-code shown'} onClick={toggleSource}>
{sourceShown ? Octicon.chevron_right : Octicon.code}
</div>
</div>
<div class={`popup-preview${previewShown ? ' shown' : ''}`}>
<PreviewPanel {...{lang, model, version, id}} shown={previewShown} />
</div>
<div class={`popup-source${sourceShown ? ' shown' : ''}`}>
<SourcePanel {...{lang, model, doCopy, doDownload, doImport}} name={modelConfig.schema ?? 'data'} />
</div>
</>
}

38
src/app/pages/Home.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { Link } from 'preact-router'
import config from '../../config.json'
import { Octicon } from '../components/Octicon'
import { locale } from '../Locales'
import { cleanUrl } from '../Utils'
type HomeProps = {
lang: string,
changeTitle: (title: string) => unknown,
path?: string,
category?: string,
}
export function Home({ lang, changeTitle, category }: HomeProps) {
const loc = locale.bind(null, lang)
changeTitle(category ? loc('title.generator_category', loc(category)) : loc('title.home'))
return <main>
<div class="home">
<div class="generator-picker">
<ul class="generators-list">
{config.models.filter(m => typeof m.category !== 'string').map(m => <li>
<Link class={`generators-card${m.category === true && m.id === category ? ' selected' : ''}`} href={cleanUrl(m.id)}>
{loc(m.id)}
{m.category && Octicon.chevron_right}
</Link>
</li>)}
</ul>
{(category && config.models.some(m => m.category === category)) &&
<ul class="generators-list">
{config.models.filter(m => m.category === category).map(m => <li>
<Link class="generators-card" href={cleanUrl(m.id)}>
{loc(m.id)}
</Link>
</li>)}
</ul>}
</div>
</div>
</main>
}

View File

@@ -1,135 +0,0 @@
import { DataModel, Path, ModelPath } from "@mcschema/core"
import { Octicon } from "../components/Octicon"
import { locale } from "../Locales"
import { Property } from "../state/Property"
import { hexId, stringToColor } from "../Utils"
import { View } from "../views/View"
import { NormalNoise } from './noise/NormalNoise'
import { Preview } from './Preview'
const LOCAL_STORAGE_BIOME_COLORS = 'biome_colors'
export class BiomeNoisePreview extends Preview {
static readonly noiseMaps = ['altitude', 'temperature', 'humidity', 'weirdness']
private noise: NormalNoise[]
seed: string
offsetX: number = 0
offsetY: number = 0
viewScale: Property<number>
biomeColors: Property<{ [id: string]: number[] }>
constructor() {
super()
this.seed = hexId()
this.viewScale = new Property(0)
this.biomeColors = new Property({})
this.biomeColors.set(JSON.parse(localStorage.getItem(LOCAL_STORAGE_BIOME_COLORS) ?? '{}'))
this.noise = []
this.biomeColors.watch(() => this.redraw())
}
getName() {
return 'biome-noise'
}
active(path: ModelPath) {
return path.endsWith(new Path(['generator', 'biome_source']))
&& path.push('type').get() === 'minecraft:multi_noise'
}
menu(view: View, redraw: () => void) {
return `
<div class="preview-scale btn input" data-id="${view.register(el => {
this.viewScale.watchRun(value => {
const blocks = (2 ** value) * 200
el.textContent = blocks.toFixed()
}, 'preview-controls')
})}"></div>
<div class="btn" data-id="${view.onClick(() => {
this.viewScale.set(this.viewScale.get() - 0.5)
redraw()
})}">
${Octicon.plus}
</div>
<div class="btn" data-id="${view.onClick(() => {
this.viewScale.set(this.viewScale.get() + 0.5)
redraw()
})}">
${Octicon.dash}
</div>`
}
getSize(): [number, number] {
return [200, 100]
}
draw(model: DataModel, img: ImageData) {
this.noise = BiomeNoisePreview.noiseMaps.map((id, i) => {
const config = this.state[`${id}_noise`]
return new NormalNoise(this.seed + i, config.firstOctave, config.amplitudes)
})
const biomeColorCache: {[key: string]: number[]} = {}
this.state.biomes.forEach((b: any) => {
biomeColorCache[b.biome] = this.getBiomeColor(b.biome)
})
const data = img.data
const s = (2 ** this.viewScale.get())
for (let x = 0; x < 200; x += 1) {
for (let y = 0; y < 100; y += 1) {
const i = (y * (img.width * 4)) + (x * 4)
const xx = (x - this.offsetX) * s - 100 * s
const yy = (y - this.offsetY) * s - 50 * s
const b = this.closestBiome(xx, yy)
const color = biomeColorCache[b] ?? [128, 128, 128]
data[i] = color[0]
data[i + 1] = color[1]
data[i + 2] = color[2]
data[i + 3] = 255
}
}
}
onDrag(dx: number, dy: number) {
this.offsetX += dx
this.offsetY += dy
}
private closestBiome(x: number, y: number): string {
if (!this.state.biomes || this.state.biomes.length === 0) return ''
const noise = this.noise.map(n => n.getValue(x, y, 0))
let minDist = Infinity
let minBiome = ''
for (const b of this.state.biomes) {
const dist = this.fitness(b.parameters, {altitude: noise[0], temperature: noise[1], humidity: noise[2], weirdness: noise[3], offset: 0})
if (dist < minDist) {
minDist = dist
minBiome = b.biome
}
}
return minBiome
}
private fitness(a: any, b: any) {
return (a.altitude - b.altitude) * (a.altitude - b.altitude) + (a.temperature - b.temperature) * (a.temperature - b.temperature) + (a.humidity - b.humidity) * (a.humidity - b.humidity) + (a.weirdness - b.weirdness) * (a.weirdness - b.weirdness) + (a.offset - b.offset) * (a.offset - b.offset)
}
getBiomeColor(biome: string): number[] {
const color = this.biomeColors.get()[biome]
if (color === undefined) {
return stringToColor(biome)
}
return color
}
setBiomeColor(biome: string, value: string) {
const color = [parseInt(value.slice(1, 3), 16), parseInt(value.slice(3, 5), 16), parseInt(value.slice(5, 7), 16)]
this.biomeColors.set({...this.biomeColors.get(), [biome]: color})
localStorage.setItem(LOCAL_STORAGE_BIOME_COLORS, JSON.stringify(this.biomeColors.get()))
}
getBiomeHex(biome: string): string {
return '#' + this.getBiomeColor(biome).map(e => e.toString(16).padStart(2, '0')).join('')
}
}

View File

@@ -1,341 +0,0 @@
import { DataModel, Path, ModelPath } from "@mcschema/core"
import seedrandom from "seedrandom"
import { App } from "../App"
import { clamp, hashString, hexId, stringToColor } from "../Utils"
import { PerlinNoise } from "./noise/PerlinNoise"
import { Preview } from './Preview'
import { Octicon } from '../components/Octicon'
import { View } from "../views/View"
type BlockPos = [number, number, number]
type Placement = { pos: BlockPos, feature: number }
const terrain = [50, 50, 51, 51, 52, 52, 53, 54, 56, 57, 57, 58, 58, 59, 60, 60, 60, 59, 59, 59, 60, 61, 61, 62, 63, 63, 64, 64, 64, 65, 65, 66, 66, 65, 65, 66, 66, 67, 67, 67, 68, 69, 71, 73, 74, 76, 79, 80, 81, 81, 82, 83, 83, 82, 82, 81, 81, 80, 80, 80, 81, 81, 82, 82]
const seaLevel = 63
const featureColors = [
[255, 77, 54], // red
[59, 118, 255], // blue
[91, 207, 25], // green
[217, 32, 245], // magenta
[255, 209, 41], // yellow
[52, 204, 209], // cyan
]
export class DecoratorPreview extends Preview {
private seed: string
private perspective: string
private size: [number, number, number]
private random: seedrandom.prng
private biomeInfoNoise: PerlinNoise
private usedFeatures: string[]
constructor() {
super()
this.seed = hexId()
this.perspective = 'top'
this.size = [64, 128, 48]
this.random = seedrandom(this.seed)
this.biomeInfoNoise = new PerlinNoise(hexId(), 0, [1])
this.usedFeatures = []
}
getName() {
return 'decorator'
}
active(path: ModelPath) {
return App.model.get()?.id === 'worldgen/feature'
&& path.equals(new Path(['config', 'decorator']))
&& path.pop().pop().push('type').get() === 'minecraft:decorated'
}
menu(view: View, redraw: () => void) {
return `
<div class="btn" data-id="${view.onClick(() => {
this.perspective = this.perspective === 'top' ? 'side' : 'top'
redraw()
})}">
${Octicon.package}
</div>`
}
getSize(): [number, number] {
return this.perspective === 'top' ? [this.size[0], this.size[2]] : [this.size[0], this.size[1]]
}
draw(model: DataModel, img: ImageData) {
const featureData = JSON.parse(JSON.stringify(model.data))
this.random = seedrandom(this.seed)
this.usedFeatures = []
let placements: Placement[] = []
for (let x = 0; x < this.size[0]/16; x += 1) {
for (let z = 0; z < (this.perspective === 'top' ? this.size[2]/16 : 1); z += 1) {
const chunkPlacements = this.getPlacements([x * 16, 0, z * 16], featureData)
const filtered = chunkPlacements.filter(p => {
return p.pos.every((n, i) => n >= 0 && n < this.size[i])
})
placements = [...placements, ...filtered]
}
}
const data = img.data
img.data.fill(255)
if (this.perspective === 'side') {
for (let x = 0; x < this.size[0]; x += 1) {
for (let y = 0; y < terrain[clamp(0, 63, x)]; y += 1) {
const i = ((this.size[1] - y - 1) * (img.width * 4)) + (x * 4)
for (let j = 0; j < 3; j += 1) {
data[i + j] = 30
}
}
for (let y = terrain[clamp(0, 63, x)]; y < seaLevel; y += 1) {
const i = ((this.size[1] - y - 1) * (img.width * 4)) + (x * 4)
data[i + 0] = 108
data[i + 1] = 205
data[i + 2] = 230
}
}
}
for (let {pos, feature} of placements) {
const i = this.perspective === 'top'
? (pos[2] * (img.width * 4)) + (pos[0] * 4)
: ((this.size[1] - pos[1] - 1) * (img.width * 4)) + (pos[0] * 4)
const color = feature < featureColors.length ? featureColors[feature] : stringToColor(this.usedFeatures[feature])
data.set(color.map(c => clamp(50, 205, c)), i)
}
for (let x = 0; x < this.size[0]; x += 1) {
for (let y = 0; y < (this.perspective === 'top' ? this.size[2]: this.size[1]); y += 1) {
if ((Math.floor(x/16) + (this.perspective === 'top' ? Math.floor(y/16) : 0)) % 2 === 0) continue
const i = (y * (img.width * 4)) + (x * 4)
for (let j = 0; j < 3; j += 1) {
data[i + j] = 0.85 * data[i + j]
}
}
}
}
private useFeature(s: string) {
const i = this.usedFeatures.indexOf(s)
if (i != -1) return i
this.usedFeatures.push(s)
return this.usedFeatures.length - 1
}
private getPlacements (pos: BlockPos, feature: any): Placement[] {
if (typeof feature === 'string') {
return [{ pos, feature: this.useFeature(feature) }]
}
const type = feature?.type?.replace(/^minecraft:/, '')
const featureFn = this.Features[type]
if (!featureFn) {
return [{ pos, feature: this.useFeature(JSON.stringify(feature)) }]
}
return featureFn(feature.config, pos)
}
private getPositions (pos: BlockPos, decorator: any): BlockPos[] {
const type = decorator?.type?.replace(/^minecraft:/, '')
const decoratorFn = this.Decorators[type]
if (!decoratorFn) {
return [pos]
}
return decoratorFn(decorator?.config, pos)
}
private decorateY(pos: BlockPos, y: number): BlockPos[] {
return [[ pos[0], y, pos[2] ]]
}
private sampleUniformInt(value: any): number {
if (typeof value === 'number') {
return value
} else {
return (value.base ?? 1) + this.nextInt(1 + (value.spread ?? 0))
}
}
private nextInt(max: number): number {
return Math.floor(this.random() * max)
}
private Features: {
[key: string]: (config: any, pos: BlockPos) => Placement[]
} = {
decorated: (config, pos) => {
const positions = this.getPositions(pos, config?.decorator)
return positions.flatMap(p => this.getPlacements(p, config?.feature))
},
random_boolean_selector: (config, pos) => {
const feature = this.random() < 0.5 ? config?.feature_true : config?.feature_false
return this.getPlacements(pos, feature)
},
random_selector: (config, pos) => {
for (const f of config?.features ?? []) {
if (this.random() < (f?.chance ?? 0)) {
return this.getPlacements(pos, f.feature)
}
}
return this.getPlacements(pos, config?.default)
},
simple_random_selector: (config, pos) => {
const feature = config?.features?.[this.nextInt(config?.features?.length ?? 0)]
return this.getPlacements(pos, feature)
}
}
private Decorators: {
[key: string]: (config: any, pos: BlockPos) => BlockPos[]
} = {
chance: (config, pos) => {
return this.random() < 1 / (config?.chance ?? 1) ? [pos] : []
},
count: (config, pos) => {
return new Array(this.sampleUniformInt(config?.count ?? 1)).fill(pos)
},
count_extra: (config, pos) => {
let count = config?.count ?? 1
if (this.random() < config.extra_chance ?? 0){
count += config.extra_count ?? 0
}
return new Array(count).fill(pos)
},
count_multilayer: (config, pos) => {
return new Array(this.sampleUniformInt(config?.count ?? 1)).fill(pos)
.map(p => [
p[0] + this.nextInt(16),
p[1],
p[2] + this.nextInt(16)
])
},
count_noise: (config, pos) => {
const noise = this.biomeInfoNoise.getValue(pos[0] / 200, 0, pos[2] / 200)
const count = noise < config.noise_level ? config.below_noise : config.above_noise
return new Array(count).fill(pos)
},
count_noise_biased: (config, pos) => {
const factor = Math.max(1, config.noise_factor)
const noise = this.biomeInfoNoise.getValue(pos[0] / factor, 0, pos[2] / factor)
const count = Math.max(0, Math.ceil((noise + config.noise_offset) * config.noise_to_count_ratio))
return new Array(count).fill(pos)
},
dark_oak_tree: (config, pos) => {
return [...new Array(16)].map((e, i) => {
const x = Math.floor(i / 4) * 4 + 1 + this.nextInt(3) + pos[0]
const y = Math.max(seaLevel, terrain[clamp(0, 63, x)])
const z = Math.floor(i % 4) * 4 + 1 + this.nextInt(3) + pos[2]
return [x, y, z]
})
},
decorated: (config, pos) => {
return this.getPositions(pos, config?.outer).flatMap(p => {
return this.getPositions(p, config?.inner)
})
},
depth_average: (config, pos) => {
const y = this.nextInt(config?.spread ?? 0) + this.nextInt(config?.spread ?? 0) - (config.spread ?? 0) + (config?.baseline ?? 0)
return this.decorateY(pos, y)
},
emerald_ore: (config, pos) => {
const count = 3 + this.nextInt(6)
return [...new Array(count)].map(e => [
this.nextInt(16) + pos[0],
this.nextInt(28) + 4,
this.nextInt(16) + pos[2]
])
},
fire: (config, pos) => {
const count = this.nextInt(this.nextInt(this.sampleUniformInt(config?.count))) + 1
return [...new Array(count)].map(e => [
this.nextInt(16) + pos[0],
this.nextInt(120) + 4,
this.nextInt(16) + pos[2]
])
},
glowstone: (config, pos) => {
const count = this.nextInt(this.nextInt(this.sampleUniformInt(config?.count)) + 1)
return [...new Array(count)].map(e => [
this.nextInt(16) + pos[0],
this.nextInt(120) + 4,
this.nextInt(16) + pos[2]
])
},
heightmap: (config, pos) => {
const y = Math.max(seaLevel, terrain[clamp(0, 63, pos[0])])
return this.decorateY(pos, y)
},
heightmap_spread_double: (config, pos) => {
const y = Math.max(seaLevel, terrain[clamp(0, 63, pos[0])])
return this.decorateY(pos, this.nextInt(y * 2))
},
heightmap_world_surface: (config, pos) => {
const y = Math.max(seaLevel, terrain[clamp(0, 63, pos[0])])
return this.decorateY(pos, y)
},
iceberg: (config, pos) => {
return [[
this.nextInt(8) + 4 + pos[0],
pos[1],
this.nextInt(8) + 4 + pos[2]
]]
},
lava_lake: (config, pos) => {
if (this.nextInt((config.chance ?? 1) / 10) === 0) {
const y = this.nextInt(this.nextInt(256 - 8) + 8)
if (y < seaLevel || this.nextInt((config?.chance ?? 1) / 8) == 0) {
const x = this.nextInt(16) + pos[0]
const z = this.nextInt(16) + pos[2]
return [[x, y, z]]
}
}
return []
},
nope: (config, pos) => {
return [pos]
},
range: (config, pos) => {
const y = this.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0)
return this.decorateY(pos, y)
},
range_biased: (config, pos) => {
const y = this.nextInt(this.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0))
return this.decorateY(pos, y)
},
range_very_biased: (config, pos) => {
const y = this.nextInt(this.nextInt(this.nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0)) + (config?.bottom_offset ?? 0)) + (config?.bottom_offset ?? 0))
return this.decorateY(pos, y)
},
spread_32_above: (config, pos) => {
const y = this.nextInt(pos[1] + 32)
return this.decorateY(pos, y)
},
top_solid_heightmap: (config, pos) => {
const y = terrain[clamp(0, 63, pos[0])]
return this.decorateY(pos, y)
},
magma: (config, pos) => {
const y = this.nextInt(pos[1] + 32)
return this.decorateY(pos, y)
},
square: (config, pos) => {
return [[
pos[0] + this.nextInt(16),
pos[1],
pos[2] + this.nextInt(16)
]]
},
water_lake: (config, pos) => {
if (this.nextInt(config.chance ?? 1) === 0) {
return [[
this.nextInt(16) + pos[0],
this.nextInt(256),
this.nextInt(16) + pos[2]
]]
}
return []
}
}
}

View File

@@ -1,111 +0,0 @@
import { DataModel, Path, ModelPath } from "@mcschema/core"
import { Preview } from './Preview'
import { toggleMenu, View } from '../views/View'
import { Octicon } from '../components/Octicon'
import { NoiseChunkGenerator } from './noise/NoiseChunkGenerator'
export class NoiseSettingsPreview extends Preview {
private width: number = 256
private depth: number = 0.1
private scale: number = 0.2
private offsetX: number = 0
private debug: boolean = false
private generator: NoiseChunkGenerator
constructor() {
super()
this.generator = new NoiseChunkGenerator()
}
getName() {
return 'noise-settings'
}
active(path: ModelPath) {
return path.endsWith(new Path(['noise']))
}
menu(view: View, redraw: () => void) {
return `<div class="panel-menu">
<div class="btn" data-id="${view.onClick(toggleMenu)}">
${Octicon.kebab_horizontal}
</div>
<div class="panel-menu-list btn-group">
<div class="btn input">
${Octicon.gear}
<label data-i18n="preview.depth"></label>
<input type="number" step="0.1" data-id="${view.register(el => {
(el as HTMLInputElement).value = this.depth.toString()
el.addEventListener('change', () => {
this.depth = parseFloat((el as HTMLInputElement).value)
redraw()
})
})}">
</div>
<div class="btn input">
${Octicon.gear}
<label data-i18n="preview.scale"></label>
<input type="number" step="0.1" data-id="${view.register(el => {
(el as HTMLInputElement).value = this.scale.toString()
el.addEventListener('change', () => {
this.scale = parseFloat((el as HTMLInputElement).value)
redraw()
})
})}">
</div>
<div class="btn input">
${Octicon.arrow_both}
<label data-i18n="preview.width"></label>
<input type="number" step="16" data-id="${view.register(el => {
(el as HTMLInputElement).value = this.width.toString()
el.addEventListener('change', () => {
this.width = parseFloat((el as HTMLInputElement).value)
redraw()
})
})}">
</div>
<div class="btn" data-id="${view.onClick(() => {this.debug = !this.debug; redraw()})}">
${Octicon.square}
<span data-i18n="preview.show_density"></span>
</div>
</div>
</div>`
}
getSize(): [number, number] {
return [this.width, this.state.height]
}
draw(model: DataModel, img: ImageData) {
this.generator.reset(this.state, this.depth, this.scale, this.offsetX, this.width)
const data = img.data
for (let x = 0; x < this.width; x += 1) {
const noise = this.generator.iterateNoiseColumn(x + this.offsetX).reverse()
for (let y = 0; y < this.state.height; y += 1) {
const i = (y * (img.width * 4)) + (x * 4)
const color = this.getColor(noise, y)
data[i] = (this.debug && noise[y] > 0) ? 255 : color
data[i + 1] = color
data[i + 2] = color
data[i + 3] = 255
}
}
}
onDrag(dx: number, dy: number) {
this.offsetX -= dx
}
private getColor(noise: number[], y: number): number {
if (this.debug) {
return -noise[y] / 2 + 128
}
if (noise[y] > 0) {
return 0
}
if (noise[y+1] > 0) {
return 150
}
return 255
}
}

View File

@@ -1,23 +0,0 @@
import { DataModel, ModelPath } from "@mcschema/core"
import { View } from "../views/View"
export abstract class Preview {
state: any
path?: ModelPath
redraw: () => void = () => {}
dirty(path: ModelPath): boolean {
return JSON.stringify(this.state) !== JSON.stringify(path.get())
}
menu(view: View, redraw: () => void): string {
return ''
}
abstract getSize(): [number, number]
abstract getName(): string
abstract active(path: ModelPath): boolean
abstract draw(model: DataModel, img: ImageData): void
onDrag(dx: number, dy: number): void {}
}

View File

@@ -1,79 +0,0 @@
import seedrandom from 'seedrandom'
import { lerp3, smoothstep } from '../../Utils'
export class ImprovedNoise {
private static readonly GRADIENT = [[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1], [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1], [1, 1, 0], [0, -1, 1], [-1, 1, 0], [0, -1, -1]]
private p: number[]
public xo: number
public yo: number
public zo: number
constructor(random: seedrandom.prng) {
this.xo = random() * 256
this.yo = random() * 256
this.zo = random() * 256
this.p = Array(256)
for (let i = 0; i < 256; i += 1) {
this.p[i] = i
}
for (let i = 0; i < 256; i += 1) {
const n = random.int32() % (256 - i)
const b = this.p[i]
this.p[i] = this.p[i + n]
this.p[i + n] = b
}
}
public noise(x: number, y: number, z: number, a: number, b: number) {
const x2 = x + this.xo
const y2 = y + this.yo
const z2 = z + this.zo
const x3 = Math.floor(x2)
const y3 = Math.floor(y2)
const z3 = Math.floor(z2)
const x4 = x2 - x3
const y4 = y2 - y3
const z4 = z2 - z3
const x5 = smoothstep(x4)
const y5 = smoothstep(y4)
const z5 = smoothstep(z4)
let y6 = 0
if (a !== 0) {
y6 = Math.floor(Math.min(b, y4) / a) * a
}
return this.sampleAndLerp(x3, y3, z3, x4, y4 - y6, z4, x5, y5, z5)
}
private gradDot(a: number, b: number, c: number, d: number) {
const grad = ImprovedNoise.GRADIENT[a & 15]
return grad[0] * b + grad[1] * c + grad[2] * d;
}
private P(i: number) {
return this.p[i & 255] & 255
}
public sampleAndLerp(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) {
const j = this.P(a) + b
const k = this.P(j) + c
const l = this.P(j + 1) + c
const m = this.P(a + 1) + b
const n = this.P(m) + c
const o = this.P(m + 1) + c
const p = this.gradDot(this.P(k), d, e, f)
const q = this.gradDot(this.P(n), d - 1, e, f)
const r = this.gradDot(this.P(l), d, e - 1, f)
const s = this.gradDot(this.P(o), d - 1, e - 1, f)
const t = this.gradDot(this.P(k + 1), d, e, f - 1)
const u = this.gradDot(this.P(n + 1), d - 1, e, f - 1)
const v = this.gradDot(this.P(l + 1), d, e - 1, f - 1)
const w = this.gradDot(this.P(o + 1), d - 1, e - 1, f - 1)
return lerp3(g, h, i, p, q, r, s, t, u, v, w)
}
}

View File

@@ -1,149 +0,0 @@
import { PerlinNoise } from './PerlinNoise'
import { clampedLerp, hexId, lerp2 } from '../../Utils'
export class NoiseChunkGenerator {
private minLimitPerlinNoise: PerlinNoise
private maxLimitPerlinNoise: PerlinNoise
private mainPerlinNoise: PerlinNoise
private depthNoise: PerlinNoise
private settings: any = {}
private chunkWidth: number = 4
private chunkHeight: number = 4
private chunkCountY: number = 32
private biomeDepth: number = 0.1
private biomeScale: number = 0.2
private noiseColumnCache: (number[] | null)[] = []
private xOffset: number = 0
constructor() {
this.minLimitPerlinNoise = PerlinNoise.fromRange(hexId(), -15, 0)
this.maxLimitPerlinNoise = PerlinNoise.fromRange(hexId(), -15, 0)
this.mainPerlinNoise = PerlinNoise.fromRange(hexId(), -7, 0)
this.depthNoise = PerlinNoise.fromRange(hexId(), -15, 0)
}
public reset(settings: any, depth: number, scale: number, xOffset: number, width: number) {
this.settings = settings
this.chunkWidth = settings.size_horizontal * 4
this.chunkHeight = settings.size_vertical * 4
this.chunkCountY = Math.floor(settings.height / this.chunkHeight)
if (settings.amplified && depth > 0) {
depth = 1 + depth * 2
scale = 1 + scale * 4
}
this.biomeDepth = 0.265625 * (depth * 0.5 - 0.125);
this.biomeScale = 96.0 / (scale * 0.9 + 0.1);
this.noiseColumnCache = Array(width).fill(null)
this.xOffset = xOffset
}
public iterateNoiseColumn(x: number): number[] {
const data = Array(this.chunkCountY * this.chunkHeight)
const cx = Math.floor(x / this.chunkWidth)
const ox = Math.floor(x % this.chunkWidth) / this.chunkWidth
const noise1 = this.fillNoiseColumn(cx)
const noise2 = this.fillNoiseColumn(cx + 1)
for (let y = this.chunkCountY - 1; y >= 0; y -= 1) {
for (let yy = this.chunkHeight; yy >= 0; yy -= 1) {
const oy = yy / this.chunkHeight
const i = y * this.chunkHeight + yy
data[i] = lerp2(oy, ox, noise1[y], noise1[y+1], noise2[y], noise2[y+1]);
}
}
return data
}
private fillNoiseColumn(x: number): number[] {
const cachedColumn = this.noiseColumnCache[x - this.xOffset]
if (cachedColumn) return cachedColumn
const data = Array(this.chunkCountY + 1)
const xzScale = 684.412 * this.settings.sampling.xz_scale
const yScale = 684.412 * this.settings.sampling.y_scale
const xzFactor = xzScale / this.settings.sampling.xz_factor
const yFactor = yScale / this.settings.sampling.y_factor
const randomDensity = this.settings.random_density_offset ? this.getRandomDensity(x) : 0
for (let y = 0; y <= this.chunkCountY; y += 1) {
let noise = this.sampleAndClampNoise(x, y, this.mainPerlinNoise.getOctaveNoise(0).zo, xzScale, yScale, xzFactor, yFactor)
const yOffset = 1 - y * 2 / this.chunkCountY + randomDensity
const density = yOffset * this.settings.density_factor + this.settings.density_offset
const falloff = (density + this.biomeDepth) * this.biomeScale
noise += falloff * (falloff > 0 ? 4 : 1)
if (this.settings.top_slide.size > 0) {
noise = clampedLerp(
this.settings.top_slide.target,
noise,
(this.chunkCountY - y - (this.settings.top_slide.offset)) / (this.settings.top_slide.size)
)
}
if (this.settings.bottom_slide.size > 0) {
noise = clampedLerp(
this.settings.bottom_slide.target,
noise,
(y - (this.settings.bottom_slide.offset)) / (this.settings.bottom_slide.size)
)
}
data[y] = noise
}
this.noiseColumnCache[x - this.xOffset] = data
return data
}
private getRandomDensity(x: number): number {
const noise = this.depthNoise.getValue(x * 200, 10, this.depthNoise.getOctaveNoise(0).zo, 1, 0, true)
const a = (noise < 0) ? -noise * 0.3 : noise
const b = a * 24.575625 - 2
return (b < 0) ? b * 0.009486607142857142 : Math.min(b, 1) * 0.006640625
}
private sampleAndClampNoise(x: number, y: number, z: number, xzScale: number, yScale: number, xzFactor: number, yFactor: number): number {
let a = 0
let b = 0
let c = 0
let d = 1
for (let i = 0; i < 16; i += 1) {
const x2 = PerlinNoise.wrap(x * xzScale * d)
const y2 = PerlinNoise.wrap(y * yScale * d)
const z2 = PerlinNoise.wrap(z * xzScale * d)
const e = yScale * d
const minLimitNoise = this.minLimitPerlinNoise.getOctaveNoise(i)
if (minLimitNoise) {
a += minLimitNoise.noise(x2, y2, z2, e, y * e) / d
}
const maxLimitNoise = this.maxLimitPerlinNoise.getOctaveNoise(i)
if (maxLimitNoise) {
b += maxLimitNoise.noise(x2, y2, z2, e, y * e) / d
}
if (i < 8) {
const mainNoise = this.mainPerlinNoise.getOctaveNoise(i)
if (mainNoise) {
c += mainNoise.noise(
PerlinNoise.wrap(x * xzFactor * d),
PerlinNoise.wrap(y * yFactor * d),
PerlinNoise.wrap(z * xzFactor * d),
yFactor * d,
y * yFactor * d
) / d
}
}
d /= 2
}
return clampedLerp(a / 512, b / 512, (c / 10 + 1) / 2)
}
}

View File

@@ -1,35 +0,0 @@
import { PerlinNoise } from './PerlinNoise'
export class NormalNoise {
private valueFactor: number
private first: PerlinNoise
private second: PerlinNoise
constructor(seed: string, firstOctave: number, amplitudes: number[]) {
this.first = new PerlinNoise(seed, firstOctave, amplitudes)
this.second = new PerlinNoise(seed + 'a', firstOctave, amplitudes)
let min = +Infinity
let max = -Infinity
for (let i = 0; i < amplitudes.length; i += 1) {
if (amplitudes[i] !== 0) {
min = Math.min(min, i)
max = Math.max(max, i)
}
}
const expectedDeviation = 0.1 * (1 + 1 / (max - min + 1))
this.valueFactor = (1/6) / expectedDeviation
}
getValue(x: number, y: number, z: number) {
const x2 = x * 1.0181268882175227
const y2 = y * 1.0181268882175227
const z2 = z * 1.0181268882175227
return (this.first.getValue(x, y, z) + this.second.getValue(x2, y2, z2)) * this.valueFactor
}
private wrap(value: number) {
return value - Math.floor(value / 3.3554432E7 + 0.5) * 3.3554432E7
}
}

View File

@@ -1,54 +0,0 @@
import seedrandom from 'seedrandom'
import { ImprovedNoise } from "./ImprovedNoise";
export class PerlinNoise {
private noiseLevels: ImprovedNoise[]
private amplitudes: number[]
private lowestFreqValueFactor: number
private lowestFreqInputFactor: number
constructor(seed: string, firstOctave: number, amplitudes: number[]) {
this.amplitudes = amplitudes
this.noiseLevels = Array(this.amplitudes.length)
for (let i = 0; i < this.amplitudes.length; i += 1) {
this.noiseLevels[i] = new ImprovedNoise(seedrandom(seed))
}
this.lowestFreqInputFactor = Math.pow(2, firstOctave)
this.lowestFreqValueFactor = Math.pow(2, (amplitudes.length - 1)) / (Math.pow(2, amplitudes.length) - 1)
}
public static fromRange(seed: string, min: number, max: number) {
return new PerlinNoise(seed, min, Array(max - min + 1).fill(1))
}
public getValue(x: number, y: number, z: number, a = 0, b = 0, fixY = false) {
let value = 0
let inputF = this.lowestFreqInputFactor
let valueF = this.lowestFreqValueFactor
for (let i = 0; i < this.noiseLevels.length; i += 1) {
const noise = this.noiseLevels[i]
if (noise) {
value += this.amplitudes[i] * noise.noise(
PerlinNoise.wrap(x * inputF),
fixY ? -noise.yo : PerlinNoise.wrap(y * inputF),
PerlinNoise.wrap(z * inputF),
a * inputF,
b * inputF
) * valueF
}
inputF *= 2
valueF /= 2
}
return value
}
public getOctaveNoise(i: number) {
return this.noiseLevels[this.noiseLevels.length - 1 - i]
}
public static wrap(value: number) {
return value - Math.floor(value / 3.3554432E7 + 0.5) * 3.3554432E7
}
}

View File

@@ -0,0 +1,152 @@
import { stringToColor } from '../Utils'
import { NormalNoise } from './noise/NormalNoise'
export type BiomeColors =Record<string, number[]>
export type BiomeSourceOptions = {
biomeColors: BiomeColors,
offset: [number, number],
scale: number,
res: number,
seed: string,
}
export const NoiseMaps = ['altitude', 'temperature', 'humidity', 'weirdness']
export function biomeSource(state: any, img: ImageData, options: BiomeSourceOptions) {
switch (state?.type?.replace(/^minecraft:/, '')) {
case 'multi_noise': return multiNoise(state, img, options)
case 'fixed': return fixed(state, img, options)
case 'checkerboard': return checkerboard(state, img, options)
}
}
function fixed(state: any, img: ImageData, options: BiomeSourceOptions) {
const data = img.data
const color = getBiomeColor(state.biome, options.biomeColors)
const row = img.width * 4 / options.res
const col = 4 / options.res
for (let x = 0; x < 200; x += options.res) {
for (let y = 0; y < 200; y += options.res) {
const i = y * row + x * col
data[i] = color[0]
data[i + 1] = color[1]
data[i + 2] = color[2]
data[i + 3] = 255
}
}
}
function checkerboard(state: any, img: ImageData, options: BiomeSourceOptions) {
const biomeColorCache: BiomeColors = {}
state.biomes?.forEach((b: string) => {
biomeColorCache[b] = getBiomeColor(b, options.biomeColors)
})
const data = img.data
const ox = -options.offset[0] - 100 + options.res / 2
const oy = -options.offset[1] - 100 + options.res / 2
const row = img.width * 4 / options.res
const col = 4 / options.res
const shift = (state.scale ?? 2) + 2
const numBiomes = state.biomes?.length ?? 0
for (let x = 0; x < 200; x += options.res) {
for (let y = 0; y < 200; y += options.res) {
const i = y * row + x * col
const xx = (x + ox) * options.scale
const yy = (y + oy) * options.scale
const j = (((xx >> shift) + (yy >> shift)) % numBiomes + numBiomes) % numBiomes
const b = state.biomes?.[j]
const color = biomeColorCache[b] ?? [128, 128, 128]
data[i] = color[0]
data[i + 1] = color[1]
data[i + 2] = color[2]
data[i + 3] = 255
}
}
}
function multiNoise(state: any, img: ImageData, options: BiomeSourceOptions) {
const noise = NoiseMaps.map((id, i) => {
const config = state[`${id}_noise`]
return new NormalNoise(options.seed + i, config.firstOctave, config.amplitudes)
})
const biomeColorCache: BiomeColors = {}
state.biomes.forEach((b: any) => {
biomeColorCache[b.biome] = getBiomeColor(b.biome, options.biomeColors)
})
const data = img.data
const ox = -options.offset[0] - 100 + options.res / 2
const oy = -options.offset[1] - 100 + options.res / 2
const row = img.width * 4 / options.res
const col = 4 / options.res
for (let x = 0; x < 200; x += options.res) {
for (let y = 0; y < 200; y += options.res) {
const i = y * row + x * col
const xx = (x + ox) * options.scale
const yy = (y + oy) * options.scale
const b = closestBiome(noise, state.biomes, xx, yy)
const color = biomeColorCache[b] ?? [128, 128, 128]
data[i] = color[0]
data[i + 1] = color[1]
data[i + 2] = color[2]
data[i + 3] = 255
}
}
}
export function getBiome(state: any, x: number, y: number, options: BiomeSourceOptions): string | undefined {
const [xx, yy] = toWorld([x, y], options)
switch (state?.type?.replace(/^minecraft:/, '')) {
case 'multi_noise':
const noise = NoiseMaps.map((id, i) => {
const config = state[`${id}_noise`]
return new NormalNoise(options.seed + i, config.firstOctave, config.amplitudes)
})
return closestBiome(noise, state.biomes, xx, yy)
case 'fixed': return state.biome
case 'checkerboard':
const shift = (state.scale ?? 2) + 2
const numBiomes = state.biomes?.length ?? 0
const j = (((xx >> shift) + (yy >> shift)) % numBiomes + numBiomes) % numBiomes
return state.biomes?.[j]
}
return undefined
}
export function getBiomeColor(biome: string, biomeColors: BiomeColors) {
if (!biome) {
return [128, 128, 128]
}
const color = biomeColors[biome]
if (color === undefined) {
return stringToColor(biome)
}
return color
}
function toWorld([x, y]: [number, number], options: BiomeSourceOptions) {
const xx = (x - options.offset[0] - 100 + options.res / 2) * options.scale
const yy = (y - options.offset[1] - 100 + options.res / 2) * options.scale
return [xx, yy]
}
function closestBiome(noise: NormalNoise[], biomes: any[], x: number, y: number): string {
if (!Array.isArray(biomes) || biomes.length === 0) return ''
const n = noise.map(n => n.getValue(x, y, 0))
let minDist = Infinity
let minBiome = ''
for (const b of biomes) {
const dist = fitness(b.parameters, {altitude: n[0], temperature: n[1], humidity: n[2], weirdness: n[3], offset: 0})
if (dist < minDist) {
minDist = dist
minBiome = b.biome
}
}
return minBiome
}
function fitness(a: any, b: any) {
return (a.altitude - b.altitude) * (a.altitude - b.altitude) + (a.temperature - b.temperature) * (a.temperature - b.temperature) + (a.humidity - b.humidity) * (a.humidity - b.humidity) + (a.weirdness - b.weirdness) * (a.weirdness - b.weirdness) + (a.offset - b.offset) * (a.offset - b.offset)
}

View File

@@ -0,0 +1,309 @@
import seedrandom from 'seedrandom'
import type { VersionId } from '../Schemas'
import { clamp, stringToColor } from '../Utils'
import { PerlinNoise } from './noise/PerlinNoise'
type BlockPos = [number, number, number]
type Placement = [BlockPos, number]
type PlacementContext = {
placements: Placement[],
features: string[],
random: seedrandom.prng,
biomeInfoNoise: PerlinNoise,
seaLevel: number,
version: VersionId,
}
const terrain = [50, 50, 51, 51, 52, 52, 53, 54, 56, 57, 57, 58, 58, 59, 60, 60, 60, 59, 59, 59, 60, 61, 61, 62, 63, 63, 64, 64, 64, 65, 65, 66, 66, 65, 65, 66, 66, 67, 67, 67, 68, 69, 71, 73, 74, 76, 79, 80, 81, 81, 82, 83, 83, 82, 82, 81, 81, 80, 80, 80, 81, 81, 82, 82]
const featureColors = [
[255, 77, 54], // red
[59, 118, 255], // blue
[91, 207, 25], // green
[217, 32, 245], // magenta
[255, 209, 41], // yellow
[52, 204, 209], // cyan
]
export type DecoratorOptions = {
size: [number, number, number],
seed: string,
version: VersionId,
}
export function decorator(state: any, img: ImageData, options: DecoratorOptions) {
const random = seedrandom(options.seed)
const ctx: PlacementContext = {
placements: [],
features: [],
random,
biomeInfoNoise: new PerlinNoise(options.seed + 'frwynup', 0, [1]),
seaLevel: 63,
version: options.version,
}
for (let x = 0; x < options.size[0] / 16; x += 1) {
for (let z = 0; z < options.size[2] / 16; z += 1) {
getPlacements([x * 16, 0, z * 16], state, ctx)
}
}
const data = img.data
img.data.fill(255)
for (const [pos, feature] of ctx.placements) {
if (pos[0] < 0 || pos[1] < 0 || pos[2] < 0 || pos[0] >= options.size[0] || pos[1] >= options.size[1] || pos[2] >= options.size[2]) continue
const i = (pos[2] * (img.width * 4)) + (pos[0] * 4)
const color = feature < featureColors.length ? featureColors[feature] : stringToColor(ctx.features[feature])
data[i] = clamp(50, 205, color[0])
data[i + 1] = clamp(50, 205, color[1])
data[i + 2] = clamp(50, 205, color[2])
data[i + 3] = 255
}
for (let x = 0; x < options.size[0]; x += 1) {
for (let y = 0; y < options.size[2]; y += 1) {
if ((Math.floor(x / 16) + Math.floor(y / 16)) % 2 === 0) continue
const i = (y * (img.width * 4)) + (x * 4)
for (let j = 0; j < 3; j += 1) {
data[i + j] = 0.85 * data[i + j]
}
}
}
}
function normalize(id: string) {
return id.startsWith('minecraft:') ? id.slice(10) : id
}
function decorateY(pos: BlockPos, y: number): BlockPos[] {
return [[ pos[0], y, pos[2] ]]
}
function nextInt(max: number, ctx: PlacementContext): number {
return Math.floor(ctx.random() * max)
}
function sampleInt(value: any, ctx: PlacementContext): number {
if (typeof value === 'number') {
return value
} else if (value.base) {
return value.base ?? 1 + nextInt(1 + (value.spread ?? 0), ctx)
} else {
switch (normalize(value.type)) {
case 'constant': return value.value
case 'uniform': return value.value.min_inclusive + nextInt(value.value.max_inclusive - value.value.min_inclusive + 1, ctx)
case 'biased_to_bottom': return value.value.min_inclusive + nextInt(nextInt(value.value.max_inclusive - value.value.min_inclusive + 1, ctx) + 1, ctx)
case 'clamped': return Math.max(value.value.min_inclusive, Math.min(value.value.max_inclusive, sampleInt(value.value.source, ctx)))
}
return 1
}
}
function useFeature(s: string, ctx: PlacementContext) {
const i = ctx.features.indexOf(s)
if (i != -1) return i
ctx.features.push(s)
return ctx.features.length - 1
}
function getPlacements(pos: BlockPos, feature: any, ctx: PlacementContext): void {
if (typeof feature === 'string') {
ctx.placements.push([pos, useFeature(feature, ctx)])
return
}
const type = normalize(feature?.type ?? 'no_op')
const featureFn = Features[type]
if (featureFn) {
featureFn(feature.config, pos, ctx)
} else {
ctx.placements.push([pos, useFeature(JSON.stringify(feature), ctx)])
}
}
function getPositions(pos: BlockPos, decorator: any, ctx: PlacementContext): BlockPos[] {
const type = normalize(decorator?.type ?? 'nope')
const decoratorFn = Decorators[type]
if (!decoratorFn) {
return [pos]
}
return decoratorFn(decorator?.config, pos, ctx)
}
const Features: {
[key: string]: (config: any, pos: BlockPos, ctx: PlacementContext) => void,
} = {
decorated: (config, pos, ctx) => {
const positions = getPositions(pos, config?.decorator, ctx)
positions.forEach(p => getPlacements(p, config?.feature, ctx))
},
random_boolean_selector: (config, pos, ctx) => {
const feature = ctx.random() < 0.5 ? config?.feature_true : config?.feature_false
getPlacements(pos, feature, ctx)
},
random_selector: (config, pos, ctx) => {
for (const f of config?.features ?? []) {
if (ctx.random() < (f?.chance ?? 0)) {
getPlacements(pos, f.feature, ctx)
return
}
}
getPlacements(pos, config?.default, ctx)
},
simple_random_selector: (config, pos, ctx) => {
const feature = config?.features?.[nextInt(config?.features?.length ?? 0, ctx)]
getPlacements(pos, feature, ctx)
},
}
const Decorators: {
[key: string]: (config: any, pos: BlockPos, ctx: PlacementContext) => BlockPos[],
} = {
chance: (config, pos, ctx) => {
return ctx.random() < 1 / (config?.chance ?? 1) ? [pos] : []
},
count: (config, pos, ctx) => {
return new Array(sampleInt(config?.count ?? 1, ctx)).fill(pos)
},
count_extra: (config, pos, ctx) => {
let count = config?.count ?? 1
if (ctx.random() < config.extra_chance ?? 0){
count += config.extra_count ?? 0
}
return new Array(count).fill(pos)
},
count_multilayer: (config, pos, ctx) => {
return new Array(sampleInt(config?.count ?? 1, ctx)).fill(pos)
.map(p => [
p[0] + nextInt(16, ctx),
p[1],
p[2] + nextInt(16, ctx),
])
},
count_noise: (config, pos, ctx) => {
const noise = ctx.biomeInfoNoise.getValue(pos[0] / 200, 0, pos[2] / 200)
const count = noise < config.noise_level ? config.below_noise : config.above_noise
return new Array(count).fill(pos)
},
count_noise_biased: (config, pos, ctx) => {
const factor = Math.max(1, config.noise_factor)
const noise = ctx.biomeInfoNoise.getValue(pos[0] / factor, 0, pos[2] / factor)
const count = Math.max(0, Math.ceil((noise + (config.noise_offset ?? 0)) * config.noise_to_count_ratio))
return new Array(count).fill(pos)
},
dark_oak_tree: (_config, pos, ctx) => {
return [...new Array(16)].map((_, i) => {
const x = Math.floor(i / 4) * 4 + 1 + nextInt(3, ctx) + pos[0]
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, x)])
const z = Math.floor(i % 4) * 4 + 1 + nextInt(3, ctx) + pos[2]
return [x, y, z]
})
},
decorated: (config, pos, ctx) => {
return getPositions(pos, config?.outer, ctx).flatMap(p => {
return getPositions(p, config?.inner, ctx)
})
},
depth_average: (config, pos, ctx) => {
const y = nextInt(config?.spread ?? 0, ctx) + nextInt(config?.spread ?? 0, ctx) - (config.spread ?? 0) + (config?.baseline ?? 0)
return decorateY(pos, y)
},
emerald_ore: (_config, pos, ctx) => {
const count = 3 + nextInt(6, ctx)
return [...new Array(count)].map(() => [
pos[0] + nextInt(16, ctx),
4 + nextInt(28, ctx),
pos[2] + nextInt(16, ctx),
])
},
fire: (config, pos, ctx) => {
const count = 1 + nextInt(nextInt(sampleInt(config?.count, ctx), ctx), ctx)
return [...new Array(count)].map(() => [
pos[0] + nextInt(16, ctx),
nextInt(128, ctx),
pos[2] + nextInt(16, ctx),
])
},
glowstone: (config, pos, ctx) => {
const count = nextInt(1 + nextInt(sampleInt(config?.count, ctx), ctx), ctx)
return [...new Array(count)].map(() => [
pos[0] + nextInt(16, ctx),
nextInt(128, ctx),
pos[2] + nextInt(16, ctx),
])
},
heightmap: (_config, pos, ctx) => {
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
return decorateY(pos, y)
},
heightmap_spread_double: (_config, pos, ctx) => {
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
return decorateY(pos, nextInt(y * 2, ctx))
},
heightmap_world_surface: (_config, pos, ctx) => {
const y = Math.max(ctx.seaLevel, terrain[clamp(0, 63, pos[0])])
return decorateY(pos, y)
},
iceberg: (_config, pos, ctx) => {
return [[
pos[0] + 4 + nextInt(8, ctx),
pos[1],
pos[2] + 4 + nextInt(8, ctx),
]]
},
lava_lake: (config, pos, ctx) => {
if (nextInt((config.chance ?? 1) / 10, ctx) === 0) {
const y = nextInt(nextInt(256 - 8, ctx) + 8, ctx)
if (y < ctx.seaLevel || nextInt((config?.chance ?? 1) / 8, ctx) == 0) {
const x = nextInt(16, ctx) + pos[0]
const z = nextInt(16, ctx) + pos[2]
return [[x, y, z]]
}
}
return []
},
nope: (_config, pos) => {
return [pos]
},
range: (config, pos, ctx) => {
const y = nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0), ctx) + (config?.bottom_offset ?? 0)
return decorateY(pos, y)
},
range_biased: (config, pos, ctx) => {
const y = nextInt(nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0), ctx) + (config?.bottom_offset ?? 0), ctx)
return decorateY(pos, y)
},
range_very_biased: (config, pos, ctx) => {
const y = nextInt(nextInt(nextInt((config?.maximum ?? 1) - (config?.top_offset ?? 0), ctx) + (config?.bottom_offset ?? 0), ctx) + (config?.bottom_offset ?? 0), ctx)
return decorateY(pos, y)
},
spread_32_above: (_config, pos, ctx) => {
const y = nextInt(pos[1] + 32, ctx)
return decorateY(pos, y)
},
top_solid_heightmap: (_config, pos) => {
const y = terrain[clamp(0, 63, pos[0])]
return decorateY(pos, y)
},
magma: (_config, pos, ctx) => {
const y = nextInt(pos[1] + 32, ctx)
return decorateY(pos, y)
},
square: (_config, pos, ctx) => {
return [[
pos[0] + nextInt(16, ctx),
pos[1],
pos[2] + nextInt(16, ctx),
]]
},
water_lake: (config, pos, ctx) => {
if (nextInt(config.chance ?? 1, ctx) === 0) {
return [[
pos[0] + nextInt(16, ctx),
nextInt(256, ctx),
pos[2] + nextInt(16, ctx),
]]
}
return []
},
}

View File

@@ -0,0 +1,37 @@
import { NoiseChunkGenerator } from './noise/NoiseChunkGenerator'
export type NoiseSettingsOptions = {
biomeScale: number,
biomeDepth: number,
offset: number,
width: number,
seed: string,
}
export function noiseSettings(state: any, img: ImageData, options: NoiseSettingsOptions) {
const generator = new NoiseChunkGenerator(options.seed)
generator.reset(state, options.biomeDepth, options.biomeScale, options.offset, 200)
const data = img.data
const row = img.width * 4
for (let x = 0; x < options.width; x += 1) {
const noise = generator.iterateNoiseColumn(x - options.offset).reverse()
for (let y = 0; y < state.height; y += 1) {
const i = y * row + x * 4
const color = getColor(noise, y)
data[i] = color
data[i + 1] = color
data[i + 2] = color
data[i + 3] = 255
}
}
}
function getColor(noise: number[], y: number): number {
if (noise[y] > 0) {
return 0
}
if (noise[y+1] > 0) {
return 150
}
return 255
}

View File

@@ -0,0 +1,3 @@
export * from './BiomeSource'
export * from './Decorator'
export * from './NoiseSettings'

View File

@@ -0,0 +1,79 @@
import type seedrandom from 'seedrandom'
import { lerp3, smoothstep } from '../../Utils'
export class ImprovedNoise {
private static readonly GRADIENT = [[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0], [1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1], [0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1], [1, 1, 0], [0, -1, 1], [-1, 1, 0], [0, -1, -1]]
private readonly p: number[]
public readonly xo: number
public readonly yo: number
public readonly zo: number
constructor(random: seedrandom.prng) {
this.xo = random() * 256
this.yo = random() * 256
this.zo = random() * 256
this.p = Array(256)
for (let i = 0; i < 256; i += 1) {
this.p[i] = i
}
for (let i = 0; i < 256; i += 1) {
const n = random.int32() % (256 - i)
const b = this.p[i]
this.p[i] = this.p[i + n]
this.p[i + n] = b
}
}
public noise(x: number, y: number, z: number, a: number, b: number) {
const x2 = x + this.xo
const y2 = y + this.yo
const z2 = z + this.zo
const x3 = Math.floor(x2)
const y3 = Math.floor(y2)
const z3 = Math.floor(z2)
const x4 = x2 - x3
const y4 = y2 - y3
const z4 = z2 - z3
const x5 = smoothstep(x4)
const y5 = smoothstep(y4)
const z5 = smoothstep(z4)
let y6 = 0
if (a !== 0) {
y6 = Math.floor(Math.min(b, y4) / a) * a
}
return this.sampleAndLerp(x3, y3, z3, x4, y4 - y6, z4, x5, y5, z5)
}
private gradDot(a: number, b: number, c: number, d: number) {
const grad = ImprovedNoise.GRADIENT[a & 15]
return grad[0] * b + grad[1] * c + grad[2] * d
}
private P(i: number) {
return this.p[i & 255] & 255
}
public sampleAndLerp(a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) {
const j = this.P(a) + b
const k = this.P(j) + c
const l = this.P(j + 1) + c
const m = this.P(a + 1) + b
const n = this.P(m) + c
const o = this.P(m + 1) + c
const p = this.gradDot(this.P(k), d, e, f)
const q = this.gradDot(this.P(n), d - 1, e, f)
const r = this.gradDot(this.P(l), d, e - 1, f)
const s = this.gradDot(this.P(o), d - 1, e - 1, f)
const t = this.gradDot(this.P(k + 1), d, e, f - 1)
const u = this.gradDot(this.P(n + 1), d - 1, e, f - 1)
const v = this.gradDot(this.P(l + 1), d, e - 1, f - 1)
const w = this.gradDot(this.P(o + 1), d - 1, e - 1, f - 1)
return lerp3(g, h, i, p, q, r, s, t, u, v, w)
}
}

View File

@@ -0,0 +1,149 @@
import { clampedLerp, lerp2 } from '../../Utils'
import { PerlinNoise } from './PerlinNoise'
export class NoiseChunkGenerator {
private readonly minLimitPerlinNoise: PerlinNoise
private readonly maxLimitPerlinNoise: PerlinNoise
private readonly mainPerlinNoise: PerlinNoise
private readonly depthNoise: PerlinNoise
private settings: any = {}
private chunkWidth: number = 4
private chunkHeight: number = 4
private chunkCountY: number = 32
private biomeDepth: number = 0.1
private biomeScale: number = 0.2
private noiseColumnCache: (number[] | null)[] = []
private xOffset: number = 0
constructor(seed: string) {
this.minLimitPerlinNoise = PerlinNoise.fromRange(seed + 'djfqnqd', -15, 0)
this.maxLimitPerlinNoise = PerlinNoise.fromRange(seed + 'gowdnqs', -15, 0)
this.mainPerlinNoise = PerlinNoise.fromRange(seed + 'afiwmco', -7, 0)
this.depthNoise = PerlinNoise.fromRange(seed + 'qphnmeo', -15, 0)
}
public reset(settings: any, depth: number, scale: number, xOffset: number, width: number) {
this.settings = settings
this.chunkWidth = settings.size_horizontal * 4
this.chunkHeight = settings.size_vertical * 4
this.chunkCountY = Math.floor(settings.height / this.chunkHeight)
if (settings.amplified && depth > 0) {
depth = 1 + depth * 2
scale = 1 + scale * 4
}
this.biomeDepth = 0.265625 * (depth * 0.5 - 0.125)
this.biomeScale = 96.0 / (scale * 0.9 + 0.1)
this.noiseColumnCache = Array(width).fill(null)
this.xOffset = xOffset
}
public iterateNoiseColumn(x: number): number[] {
const data = Array(this.chunkCountY * this.chunkHeight)
const cx = Math.floor(x / this.chunkWidth)
const ox = Math.floor(x % this.chunkWidth) / this.chunkWidth
const noise1 = this.fillNoiseColumn(cx)
const noise2 = this.fillNoiseColumn(cx + 1)
for (let y = this.chunkCountY - 1; y >= 0; y -= 1) {
for (let yy = this.chunkHeight; yy >= 0; yy -= 1) {
const oy = yy / this.chunkHeight
const i = y * this.chunkHeight + yy
data[i] = lerp2(oy, ox, noise1[y], noise1[y+1], noise2[y], noise2[y+1])
}
}
return data
}
private fillNoiseColumn(x: number): number[] {
const cachedColumn = this.noiseColumnCache[x - this.xOffset]
if (cachedColumn) return cachedColumn
const data = Array(this.chunkCountY + 1)
const xzScale = 684.412 * this.settings.sampling.xz_scale
const yScale = 684.412 * this.settings.sampling.y_scale
const xzFactor = xzScale / this.settings.sampling.xz_factor
const yFactor = yScale / this.settings.sampling.y_factor
const randomDensity = this.settings.random_density_offset ? this.getRandomDensity(x) : 0
for (let y = 0; y <= this.chunkCountY; y += 1) {
let noise = this.sampleAndClampNoise(x, y, this.mainPerlinNoise.getOctaveNoise(0).zo, xzScale, yScale, xzFactor, yFactor)
const yOffset = 1 - y * 2 / this.chunkCountY + randomDensity
const density = yOffset * this.settings.density_factor + this.settings.density_offset
const falloff = (density + this.biomeDepth) * this.biomeScale
noise += falloff * (falloff > 0 ? 4 : 1)
if (this.settings.top_slide.size > 0) {
noise = clampedLerp(
this.settings.top_slide.target,
noise,
(this.chunkCountY - y - (this.settings.top_slide.offset)) / (this.settings.top_slide.size)
)
}
if (this.settings.bottom_slide.size > 0) {
noise = clampedLerp(
this.settings.bottom_slide.target,
noise,
(y - (this.settings.bottom_slide.offset)) / (this.settings.bottom_slide.size)
)
}
data[y] = noise
}
this.noiseColumnCache[x - this.xOffset] = data
return data
}
private getRandomDensity(x: number): number {
const noise = this.depthNoise.getValue(x * 200, 10, this.depthNoise.getOctaveNoise(0).zo, 1, 0, true)
const a = (noise < 0) ? -noise * 0.3 : noise
const b = a * 24.575625 - 2
return (b < 0) ? b * 0.009486607142857142 : Math.min(b, 1) * 0.006640625
}
private sampleAndClampNoise(x: number, y: number, z: number, xzScale: number, yScale: number, xzFactor: number, yFactor: number): number {
let a = 0
let b = 0
let c = 0
let d = 1
for (let i = 0; i < 16; i += 1) {
const x2 = PerlinNoise.wrap(x * xzScale * d)
const y2 = PerlinNoise.wrap(y * yScale * d)
const z2 = PerlinNoise.wrap(z * xzScale * d)
const e = yScale * d
const minLimitNoise = this.minLimitPerlinNoise.getOctaveNoise(i)
if (minLimitNoise) {
a += minLimitNoise.noise(x2, y2, z2, e, y * e) / d
}
const maxLimitNoise = this.maxLimitPerlinNoise.getOctaveNoise(i)
if (maxLimitNoise) {
b += maxLimitNoise.noise(x2, y2, z2, e, y * e) / d
}
if (i < 8) {
const mainNoise = this.mainPerlinNoise.getOctaveNoise(i)
if (mainNoise) {
c += mainNoise.noise(
PerlinNoise.wrap(x * xzFactor * d),
PerlinNoise.wrap(y * yFactor * d),
PerlinNoise.wrap(z * xzFactor * d),
yFactor * d,
y * yFactor * d
) / d
}
}
d /= 2
}
return clampedLerp(a / 512, b / 512, (c / 10 + 1) / 2)
}
}

View File

@@ -0,0 +1,31 @@
import { PerlinNoise } from './PerlinNoise'
export class NormalNoise {
private readonly valueFactor: number
private readonly first: PerlinNoise
private readonly second: PerlinNoise
constructor(seed: string, firstOctave: number, amplitudes: number[]) {
this.first = new PerlinNoise(seed, firstOctave, amplitudes)
this.second = new PerlinNoise(seed + 'a', firstOctave, amplitudes)
let min = +Infinity
let max = -Infinity
for (let i = 0; i < amplitudes.length; i += 1) {
if (amplitudes[i] !== 0) {
min = Math.min(min, i)
max = Math.max(max, i)
}
}
const expectedDeviation = 0.1 * (1 + 1 / (max - min + 1))
this.valueFactor = (1/6) / expectedDeviation
}
getValue(x: number, y: number, z: number) {
const x2 = x * 1.0181268882175227
const y2 = y * 1.0181268882175227
const z2 = z * 1.0181268882175227
return (this.first.getValue(x, y, z) + this.second.getValue(x2, y2, z2)) * this.valueFactor
}
}

View File

@@ -0,0 +1,54 @@
import seedrandom from 'seedrandom'
import { ImprovedNoise } from './ImprovedNoise'
export class PerlinNoise {
private readonly noiseLevels: ImprovedNoise[]
private readonly amplitudes: number[]
private readonly lowestFreqValueFactor: number
private readonly lowestFreqInputFactor: number
constructor(seed: string, firstOctave: number, amplitudes: number[]) {
this.amplitudes = amplitudes
this.noiseLevels = Array(this.amplitudes.length)
for (let i = 0; i < this.amplitudes.length; i += 1) {
this.noiseLevels[i] = new ImprovedNoise(seedrandom(seed))
}
this.lowestFreqInputFactor = Math.pow(2, firstOctave)
this.lowestFreqValueFactor = Math.pow(2, (amplitudes.length - 1)) / (Math.pow(2, amplitudes.length) - 1)
}
public static fromRange(seed: string, min: number, max: number) {
return new PerlinNoise(seed, min, Array(max - min + 1).fill(1))
}
public getValue(x: number, y: number, z: number, a = 0, b = 0, fixY = false) {
let value = 0
let inputF = this.lowestFreqInputFactor
let valueF = this.lowestFreqValueFactor
for (let i = 0; i < this.noiseLevels.length; i += 1) {
const noise = this.noiseLevels[i]
if (noise) {
value += this.amplitudes[i] * noise.noise(
PerlinNoise.wrap(x * inputF),
fixY ? -noise.yo : PerlinNoise.wrap(y * inputF),
PerlinNoise.wrap(z * inputF),
a * inputF,
b * inputF
) * valueF
}
inputF *= 2
valueF /= 2
}
return value
}
public getOctaveNoise(i: number) {
return this.noiseLevels[this.noiseLevels.length - 1 - i]
}
public static wrap(value: number) {
return value - Math.floor(value / 3.3554432E7 + 0.5) * 3.3554432E7
}
}

36
src/app/schema/Mounter.ts Normal file
View File

@@ -0,0 +1,36 @@
import { hexId } from '../Utils'
export class Mounter {
private registry: { [id: string]: (el: Element) => void } = {}
register(callback: (el: Element) => void): string {
const id = hexId()
this.registry[id] = callback
return id
}
on(type: string, callback: (el: Element) => void): string {
return this.register(el => {
el.addEventListener(type, evt => {
callback(el)
evt.stopPropagation()
})
})
}
onChange(callback: (el: Element) => void): string {
return this.on('change', callback)
}
onClick(callback: (el: Element) => void): string {
return this.on('click', callback)
}
mounted(el: Element): void {
el.querySelectorAll('[data-id]').forEach(el => {
const id = el.getAttribute('data-id')!
this.registry[id]?.(el)
})
this.registry = {}
}
}

View File

@@ -0,0 +1,7 @@
export const Octicon = {
clippy: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.75 1a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-3a.75.75 0 00-.75-.75h-4.5zm.75 3V2.5h3V4h-3zm-2.874-.467a.75.75 0 00-.752-1.298A1.75 1.75 0 002 3.75v9.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 13.25v-9.5a1.75 1.75 0 00-.874-1.515.75.75 0 10-.752 1.298.25.25 0 01.126.217v9.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-9.5a.25.25 0 01.126-.217z"></path></svg>',
info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"></path></svg>',
issue_opened: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"></path></svg>',
plus_circle: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"></path></svg>',
trashcan: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path></svg>',
}

View File

@@ -0,0 +1,367 @@
import type { EnumOption, Hook, ValidationOption } from '@mcschema/core'
import { DataModel, MapNode, ModelPath, Path, StringNode } from '@mcschema/core'
import type { Localize } from '../Locales'
import type { VersionId } from '../Schemas'
import { hexId, htmlEncode } from '../Utils'
import type { Mounter } from './Mounter'
import { Octicon } from './Octicon'
export type TreeProps = {
loc: Localize,
mounter: Mounter,
version: VersionId,
}
const selectRegistries = ['loot_table.type', 'loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'carver.type', 'feature.type', 'decorator.type', 'feature.tree.minimum_size.type', 'block_state_provider.type', 'trunk_placer.type', 'foliage_placer.type', 'tree_decorator.type', 'int_provider.type', 'float_provider.type', 'height_provider.type', 'structure_feature.type', 'surface_builder.type', 'processor.processor_type', 'rule_test.predicate_type', 'pos_rule_test.predicate_type', 'template_element.element_type', 'block_placer.type']
const hiddenFields = ['number_provider.type', 'score_provider.type', 'nbt_provider.type', 'int_provider.type', 'float_provider.type', 'height_provider.type']
const flattenedFields = ['feature.config', 'decorator.config', 'int_provider.value', 'float_provider.value', 'block_state_provider.simple_state_provider.state', 'block_state_provider.rotated_block_provider.state', 'block_state_provider.weighted_state_provider.entries.entry.data', 'rule_test.block_state', 'structure_feature.config', 'surface_builder.config', 'template_pool.elements.entry.element']
const inlineFields = ['loot_entry.type', 'function.function', 'condition.condition', 'criterion.trigger', 'dimension.generator.type', 'dimension.generator.biome_source.type', 'feature.type', 'decorator.type', 'block_state_provider.type', 'feature.tree.minimum_size.type', 'trunk_placer.type', 'feature.tree.foliage_placer', 'tree_decorator.type', 'block_placer.type', 'rule_test.predicate_type', 'processor.processor_type', 'template_element.element_type']
/**
* Secondary model used to remember the keys of a map
*/
const keysModel = new DataModel(MapNode(
StringNode(),
StringNode()
), { historyMax: 0 })
/**
* Renders the node and handles events to update the model
* @returns string HTML representation of this node using the given data
*/
export const renderHtml: Hook<[any, TreeProps], [string, string, string]> = {
base() {
return ['', '', '']
},
boolean({ node }, path, value, { loc, mounter }) {
const onFalse = mounter.onClick(() => {
path.model.set(path, node.optional() && value === false ? undefined : false)
})
const onTrue = mounter.onClick(() => {
path.model.set(path, node.optional() && value === true ? undefined : true)
})
return ['', `<button${value === false ? ' class="selected"' : ' '}
data-id="${onFalse}">${htmlEncode(loc('false'))}</button>
<button${value === true ? ' class="selected"' : ' '}
data-id="${onTrue}">${htmlEncode(loc('true'))}</button>`, '']
},
choice({ choices, config, switchNode }, path, value, { loc, mounter, version }) {
const choice = switchNode.activeCase(path, true)
const pathWithContext = (config?.context) ? new ModelPath(path.getModel(), new Path(path.getArray(), [config.context])) : path
const pathWithChoiceContext = config?.choiceContext ? new Path([], [config.choiceContext]) : config?.context ? new Path([], [config.context]) : path
const [prefix, suffix, body] = choice.node.hook(this, pathWithContext, value, { loc, mounter, version })
if (choices.length === 1) {
return [prefix, suffix, body]
}
const inputId = mounter.register(el => {
(el as HTMLSelectElement).value = choice.type
el.addEventListener('change', () => {
const c = choices.find(c => c.type === (el as HTMLSelectElement).value) ?? choice
path.model.set(path, c.change ? c.change(value) : c.node.default())
})
})
const inject = `<select data-id="${inputId}">
${choices.map(c => `<option value="${htmlEncode(c.type)}">
${htmlEncode(pathLocale(loc, pathWithChoiceContext.contextPush(c.type)))}
</option>`).join('')}
</select>`
return [prefix, inject + suffix, body]
},
list({ children }, path, value, { loc, mounter, version }) {
const onAdd = mounter.onClick(() => {
if (!Array.isArray(value)) value = []
path.model.set(path, [children.default(), ...value])
})
const onAddBottom = mounter.onClick(() => {
if (!Array.isArray(value)) value = []
path.model.set(path, [...value, children.default()])
})
const suffix = `<button class="add" data-id="${onAdd}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>`
let body = ''
if (Array.isArray(value)) {
body = value.map((childValue, index) => {
const removeId = mounter.onClick(() => path.model.set(path.push(index), undefined))
const childPath = path.push(index).contextPush('entry')
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, childValue, { loc, mounter, version })
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
<button class="remove" data-id="${removeId}" aria-label="${loc('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
${htmlEncode(pathLocale(loc, childPath, `${index}`))}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div>
</div>`
}).join('')
if (value.length > 2) {
body += `<div class="node-entry">
<div class="node node-header">
<button class="add" data-id="${onAddBottom}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>
</div>
</div>`
}
}
return ['', suffix, body]
},
map({ children, keys }, path, value, { loc, mounter, version }) {
const keyPath = new ModelPath(keysModel, new Path([hashString(path.toString())]))
const onAdd = mounter.onClick(() => {
const key = keyPath.get()
path.model.set(path.push(key), children.default())
})
const keyRendered = keys.hook(this, keyPath, keyPath.get() ?? '', { loc, mounter, version })
const suffix = keyRendered[1] + `<button class="add" data-id="${onAdd}" aria-label="${loc('button.add')}">${Octicon.plus_circle}</button>`
let body = ''
if (typeof value === 'object' && value !== undefined) {
body = Object.keys(value)
.map(key => {
const onRemove = mounter.onClick(() => path.model.set(path.push(key), undefined))
const childPath = path.modelPush(key)
const category = children.category(childPath)
const [cPrefix, cSuffix, cBody] = children.hook(this, childPath, value[key], { loc, mounter, version })
return `<div class="node-entry"><div class="node ${children.type(childPath)}-node" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
<button class="remove" data-id="${onRemove}" aria-label="${loc('button.remove')}">${Octicon.trashcan}</button>
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
${htmlEncode(key)}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div>
</div>`
})
.join('')
}
return ['', suffix, body]
},
number({ integer, config }, path, value, { mounter }) {
const onChange = mounter.onChange(el => {
const value = (el as HTMLInputElement).value
const parsed = config?.color
? parseInt(value.slice(1), 16)
: integer ? parseInt(value) : parseFloat(value)
path.model.set(path, parsed)
})
if (config?.color) {
const hex = (value?.toString(16).padStart(6, '0') ?? '000000')
return ['', `<input type="color" data-id="${onChange}" value="#${hex}">`, '']
}
return ['', `<input data-id="${onChange}" value="${value ?? ''}">`, '']
},
object({ node, getActiveFields, getChildModelPath }, path, value, { loc, mounter, version }) {
let suffix = ''
if (node.optional()) {
if (value === undefined) {
suffix = `<button class="collapse closed" data-id="${mounter.onClick(() => path.model.set(path, node.default()))}" aria-label="${loc('button.expand')}">${Octicon.plus_circle}</button>`
} else {
suffix = `<button class="collapse open" data-id="${mounter.onClick(() => path.model.set(path, undefined))}" aria-label="${loc('button.collapse')}">${Octicon.trashcan}</button>`
}
}
let body = ''
if (typeof value === 'object' && value !== undefined && (!(node.optional() && value === undefined))) {
const activeFields = getActiveFields(path)
const activeKeys = Object.keys(activeFields)
.filter(k => activeFields[k].enabled(path))
body = activeKeys.map(k => {
const field = activeFields[k]
const childPath = getChildModelPath(path, k)
const context = childPath.getContext().join('.')
if (hiddenFields.includes(context)) {
return ''
}
const category = field.category(childPath)
const [cPrefix, cSuffix, cBody] = field.hook(this, childPath, value[k], { loc, mounter, version })
if (field.type(childPath) === 'object' && flattenedFields.includes(context)) {
suffix += cSuffix
return cBody
}
if (inlineFields.includes(context)) {
suffix += cSuffix
return ''
}
return `<div class="node ${field.type(childPath)}-node ${cBody ? '' : 'no-body'}" ${category ? `data-category="${htmlEncode(category)}"` : ''}>
<div class="node-header">
${error(loc, childPath, mounter)}
${help(loc, childPath, mounter)}
${cPrefix}
<label ${contextMenu(loc, childPath, mounter)}>
${pathLocale(loc, childPath)}
</label>
${cSuffix}
</div>
${cBody ? `<div class="node-body">${cBody}</div>` : ''}
</div>`
})
.join('')
}
return ['', suffix, body]
},
string({ node, getValues, config }, path, value, { loc, mounter }) {
const inputId = mounter.register(el => {
(el as HTMLSelectElement).value = value ?? ''
el.addEventListener('change', evt => {
const newValue = (el as HTMLSelectElement).value
path.model.set(path, newValue.length === 0 ? undefined : newValue)
evt.stopPropagation()
})
})
let suffix
const values = getValues()
if ((isEnum(config) && !config.additional)
|| selectRegistries.includes(path.getContext().join('.')) ) {
let context = new Path([])
if (isEnum(config) && typeof config.enum === 'string') {
context = context.contextPush(config.enum)
} else if (!isEnum(config) && config?.validator === 'resource' && typeof config.params.pool === 'string') {
context = context.contextPush(config.params.pool)
}
suffix = `<select data-id="${inputId}">
${node.optional() ? `<option value="">${loc('unset')}</option>` : ''}
${values.map(v => `<option value="${htmlEncode(v)}">
${pathLocale(loc, context.contextPush(v.replace(/^minecraft:/, '')))}
</option>`).join('')}
</select>`
} else {
const datalistId = hexId()
suffix = `<input data-id="${inputId}" ${values.length === 0 ? '' : `list="${datalistId}"`}>
${values.length === 0 ? '' :
`<datalist id="${datalistId}">
${values.map(v =>
`<option value="${htmlEncode(v)}">`
).join('')}
</datalist>`}`
}
return ['', suffix, '']
},
}
function isEnum(value?: ValidationOption | EnumOption): value is EnumOption {
return !!(value as any)?.enum
}
function hashString(str: string) {
var hash = 0, i, chr
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0
}
return hash
}
function pathLocale(loc: Localize, path: Path, ...params: string[]) {
const ctx = path.getContext()
for (let i = 0; i < ctx.length; i += 1) {
const key = ctx.slice(i).join('.')
const result = loc(key, ...params)
if (key !== result) {
return result
}
}
return htmlEncode(ctx[ctx.length - 1])
}
function error(loc: Localize, path: ModelPath, mounter: Mounter) {
const e = path.model.errors.get(path, true)
if (e.length === 0) return ''
const message = e[0].params ? loc(e[0].error, ...e[0].params) : loc(e[0].error)
return popupIcon('node-error', 'issue_opened', htmlEncode(message), mounter)
}
function help(loc: Localize, path: Path, mounter: Mounter) {
const key = path.contextPush('help').getContext().join('.')
const message = loc(key)
if (message === key) return ''
return popupIcon('node-help', 'info', htmlEncode(message), mounter)
}
const popupIcon = (type: string, icon: keyof typeof Octicon, popup: string, mounter: Mounter) => {
const onClick = mounter.onClick(el => {
el.getElementsByTagName('span')[0].classList.add('show')
document.body.addEventListener('click', () => {
el.getElementsByTagName('span')[0].classList.remove('show')
}, { capture: true, once: true })
})
return `<div class="node-icon ${type}" data-id="${onClick}">
${Octicon[icon]}
<span class="icon-popup">${popup}</span>
</div>`
}
const contextMenu = (loc: Localize, path: ModelPath, mounter: Mounter) => {
const id = mounter.register(el => {
const openMenu = () => {
const popup = document.createElement('div')
popup.classList.add('node-menu')
const message = loc(path.contextPush('help').getContext().join('.'))
if (!message.endsWith('.help')) {
popup.insertAdjacentHTML('beforeend', `<span class="menu-item help-item">${message}</span>`)
}
const context = path.getContext().join('.')
popup.insertAdjacentHTML('beforeend', `
<div class="menu-item">
<span class="btn">${Octicon.clippy}</span>
Context:&nbsp
<span class="menu-item-context">${context}</span>
</div>`)
popup.querySelector('.menu-item .btn')?.addEventListener('click', () => {
const inputEl = document.createElement('input')
inputEl.value = context
el.appendChild(inputEl)
inputEl.select()
document.execCommand('copy')
el.removeChild(inputEl)
})
el.appendChild(popup)
document.body.addEventListener('click', () => {
try {el.removeChild(popup)} catch (e) {}
}, { capture: true, once: true })
document.body.addEventListener('contextmenu', () => {
try {el.removeChild(popup)} catch (e) {}
}, { capture: true, once: true })
}
el.addEventListener('contextmenu', evt => {
openMenu()
evt.preventDefault()
})
let timer: any = null
el.addEventListener('touchstart', () => {
timer = setTimeout(() => {
openMenu()
timer = null
}, 800)
})
el.addEventListener('touchend', () => {
if (timer) {
clearTimeout(timer)
timer = null
}
})
})
return `data-id="${id}"`
}

View File

@@ -0,0 +1,41 @@
import type { Hook } from '@mcschema/core'
export const transformOutput: Hook<[any], any> = {
base({}, _, value) {
return value
},
choice({ switchNode }, path, value) {
return switchNode.hook(this, path, value)
},
list({ children }, path, value) {
if (!Array.isArray(value)) return value
return value.map((obj, index) =>
children.hook(this, path.push(index), obj)
)
},
map({ children }, path, value) {
if (value === undefined) return undefined
const res: any = {}
Object.keys(value).forEach(f =>
res[f] = children.hook(this, path.push(f), value[f])
)
return res
},
object({ getActiveFields }, path, value) {
if (value === undefined || value === null || typeof value !== 'object') {
return value
}
const res: any = {}
const activeFields = getActiveFields(path)
Object.keys(activeFields)
.filter(k => activeFields[k].enabled(path))
.forEach(f => {
res[f] = activeFields[f].hook(this, path.push(f), value[f])
})
return res
},
}

View File

@@ -1,14 +0,0 @@
import { Property } from './Property'
export class LocalStorageProperty extends Property<string> {
constructor(private id: string, fallback: string) {
super(localStorage.getItem(id) ?? fallback)
}
set(value: string) {
super.set(value)
localStorage.setItem(this.id, value)
}
get(): string {
return this.value
}
}

View File

@@ -1,40 +0,0 @@
import { hexId } from "../Utils"
type PropertyWatcher<T> = (value: T, oldValue: T | null) => void
type NamedPropertyWatcher<T> = {
name: string
watcher: PropertyWatcher<T>
}
export class Property<T> {
private watchers: NamedPropertyWatcher<T>[] = []
constructor(public value: T) {}
set(value: T) {
if (this.value === value) return
const oldValue = this.value
this.value = value
this.watchers.forEach(w => w.watcher(this.value, oldValue))
}
get(): T {
return this.value
}
watchRun(watcher: PropertyWatcher<T>, name?: string) {
watcher(this.value, null)
return this.watch(watcher, name)
}
watch(watcher: PropertyWatcher<T>, name?: string) {
name = name ?? hexId()
const w = this.watchers.find(w => w.name === name)
if (w) {
w.watcher = watcher
} else {
this.watchers.push({ name, watcher })
}
return this
}
}

View File

@@ -1,60 +0,0 @@
import { App } from '../App'
import { Header } from '../components/Header'
import { Octicon } from '../components/Octicon'
import { locale } from '../Locales'
import { View } from './View'
export const FieldSettings = (view: View): string => {
const fieldListId = view.register(fieldList => {
const getFields = () => {
const fields = App.settings.fields
return fields.map((f, i) => {
const pathInput = view.register(el => {
(el as HTMLInputElement).value = f.path ?? ''
el.addEventListener('change', () => {
fields[i] = {...f, path: (el as HTMLSelectElement).value}
App.settings.save()
view.mount(fieldList, getFields(), false)
})
})
const nameInput = view.register(el => {
(el as HTMLInputElement).value = f.name ?? ''
el.addEventListener('change', () => {
fields[i] = {...f, name: (el as HTMLSelectElement).value}
App.settings.save()
view.mount(fieldList, getFields(), false)
})
})
return `<li>
<div class="field-prop">
<label>${locale('settings.fields.path')}</label>
<input size="30" data-id="${pathInput}">
</div>
<div class="field-prop">
<label>${locale('settings.fields.name')}</label>
<input data-id="${nameInput}">
</div>
<div class="field-prop">
<span ${f?.hidden ? 'class="hidden"' : ''} data-id="${view.onClick(() => {
fields[i].hidden = f?.hidden ? undefined : true
App.settings.save()
view.mount(fieldList, getFields(), false)
})}">${f.hidden ? Octicon.eye_closed : Octicon.eye}</span>
<span class="dimmed" data-id="${view.onClick(() => {
fields.splice(i, 1)
App.settings.save()
view.mount(fieldList, getFields(), false)
})}">${Octicon.trashcan}</span>
</div>
</li>`
}).join('')
}
view.mount(fieldList, getFields(), false)
})
return `${Header(view, 'Field Settings')}
<div class="settings">
<p>${locale('settings.fields.description')}</p>
<ul class="field-list" data-id="${fieldListId}"></ul>
</div>`
}

View File

@@ -1,70 +0,0 @@
import { App, checkVersion, Models } from '../App'
import { View } from './View'
import { Header } from '../components/Header'
import { SplitGroup } from '../components/SplitGroup'
import { Errors } from '../components/Errors'
import { TreePanel } from '../components/panels/TreePanel'
import { SourcePanel } from '../components/panels/SourcePanel'
import { PreviewPanel } from '../components/panels/PreviewPanel'
import { customValidation } from '../hooks/customValidation'
import { ModelPath, Path } from '@mcschema/core'
export const Generator = (view: View): string => {
const model = Models[App.model.get()!.id]
model.listeners = []
const getSideContent = () => {
return App.preview.get() ?
SplitGroup(view, { direction: 'vertical', sizes: [60, 40] }, [
SourcePanel(view, model),
PreviewPanel(view, model)
])
: SourcePanel(view, model)
}
const validatePreview = () => {
const preview = App.preview.get()
const path = preview?.path?.withModel(model)
if (!(path && path.get() && preview?.active(path))) {
App.preview.set(null)
}
}
model.addListener({
invalidated: () => {
validatePreview()
model.schema.hook(customValidation, new ModelPath(model, new Path()), model.data, model.errors)
}
})
App.schemasLoaded.watch((value) => {
if (value) {
model.validate()
model.invalidate()
validatePreview()
}
}, 'generator')
App.localesLoaded.watch((value) => {
if (value && App.schemasLoaded.get()) {
model.invalidate()
}
}, 'generator')
App.version.watchRun((value) => {
const minVersion = App.model.get()!.minVersion
if (minVersion && !checkVersion(value, minVersion)) {
App.version.set(minVersion)
}
}, 'generator')
const sideContent = view.register(el => {
App.preview.watch((value, oldValue) => {
if (!value || !oldValue) {
view.mount(el, getSideContent(), false)
}
}, 'generator')
})
const homeLink = typeof App.model.get()!.category === 'string' ? `/${App.model.get()!.category}/` : undefined
return `${Header(view, `${App.model.get()!.name} Generator`, homeLink)}
<div class="content">
${SplitGroup(view, { direction: "horizontal", sizes: [66, 34] }, [
TreePanel(view, model),
`<div class="content-output" data-id="${sideContent}">${getSideContent()}</div>`
])}
</div>
${Errors(view, model)}`
}

View File

@@ -1,39 +0,0 @@
import { App } from '../App'
import { Header } from '../components/Header'
import { View } from './View'
import { Octicon } from '../components/Octicon'
import config from '../../config.json'
function cleanUrl(url: string) {
url = url.startsWith('/') ? url : '/' + url
return url.endsWith('/') ? url : url + '/'
}
export const GeneratorCard = (url: string, name: string, arrow?: boolean, active?: boolean) => `
<li>
<a data-link href="${cleanUrl(url)}" class="generators-card${active ? ' selected' : ''}">
${name}
${arrow ? Octicon.chevron_right : ''}
</a>
</li>
`
export const Home = (view: View): string => {
const filteredModels = config.models.filter(m => m.category === App.model.get()!.id)
return `
${Header(view, 'Data Pack Generators')}
<div class="home">
<ul class="generators-list">
${config.models
.filter(m => typeof m.category !== 'string')
.map(m => GeneratorCard(m.id, m.name, m.category === true, App.model.get()!.id === m.id))
.join('')}
</ul>
${filteredModels.length === 0 ? '' : `
<ul class="generators-list">
${filteredModels.map(m => GeneratorCard(m.id, m.name)).join('')}
</ul>
`}
</div>
`
}

View File

@@ -1,17 +0,0 @@
import { Header } from '../components/Header'
import { View } from './View'
import { locale } from '../Locales'
import { GeneratorCard } from './Home'
export const NotFound = (view: View): string => {
return `
${Header(view, 'Data Pack Generators')}
<div class="home center">
<h2 class="very-large">404</h2>
<p>${locale('not_found.description')}</p>
<ul class="generators-list">
${GeneratorCard('/', locale('home'), true)}
</ul>
</div>
`
}

View File

@@ -1,72 +0,0 @@
import { locale } from "../Locales"
import { hexId } from "../Utils"
export interface Mounter {
register(callback: (el: Element) => void): string
onChange(callback: (el: Element) => void): string
onClick(callback: (el: Element) => void): string
mounted(el: Element, clear?: boolean): void
}
export class View implements Mounter{
private registry: { [id: string]: (el: Element) => void } = {}
render(): string {
return ''
}
register(callback: (el: Element) => void): string {
const id = hexId()
this.registry[id] = callback
return id
}
on(type: string, callback: (el: Element) => void): string {
return this.register(el => {
el.addEventListener(type, evt => {
callback(el)
evt.stopPropagation()
})
})
}
onChange(callback: (el: Element) => void): string {
return this.on('change', callback)
}
onClick(callback: (el: Element) => void): string {
return this.on('click', callback)
}
mounted(el: Element, clear = true): void {
el.querySelectorAll('[data-id]').forEach(el => {
const id = el.getAttribute('data-id')!
this.registry[id]?.(el)
})
if (clear) {
this.registry = {}
}
el.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = locale(el.attributes.getNamedItem('data-i18n')!.value)
})
}
mount(el: Element, html: string, clear = true) {
console.debug(`[View.mount] ${html.replace(/\n/g,'').slice(0, 40)}...`)
el.innerHTML = html
this.mounted(el, clear)
}
}
export const toggleMenu = (el: Element) => {
el.classList.add('active')
const hideMenu = () => document.body.addEventListener('click', evt => {
if ((evt.target as Element).matches('.btn.input') || (evt.target as Element).closest('.btn')?.classList.contains('input')) {
hideMenu()
return
}
el.classList.remove('active')
}, { capture: true, once: true })
hideMenu()
}

View File

@@ -34,7 +34,8 @@
},
{
"code": "sk",
"name": "Slovenčina"
"name": "Slovenčina",
"schemas": false
},
{
"code": "zh-cn",
@@ -197,7 +198,11 @@
{ "id": "entity_type" },
{ "id": "fluid" },
{ "id": "function", "dynamic": true },
{ "id": "float_provider_type", "minVersion": "1.17" },
{ "id": "item" },
{ "id": "int_provider_type", "minVersion": "1.17" },
{ "id": "height_provider_type", "minVersion": "1.17" },
{ "id": "loot_condition_type", "minVersion": "1.16" },
{ "id": "loot_condition_type", "minVersion": "1.16" },
{ "id": "loot_function_type", "minVersion": "1.16" },
{ "id": "loot_nbt_provider_type", "minVersion": "1.17" },

View File

@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Google Analytics -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-73024255-2', 'auto');
ga('set', 'page', location.pathname);
ga('set', 'dimension1', localStorage.getItem('theme') ?? 'default');
ga('set', 'dimension2', 'v2');
ga('set', 'dimension3', localStorage.getItem('schema_version') ?? '1.17');
ga('set', 'dimension4', localStorage.getItem('language') ?? 'en');
ga('set', 'dimension5', 'none');
ga('send', 'pageview');
</script>
<!-- End Google Analytics -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="<%= htmlWebpackPlugin.options.title %>. Generate JSON and use it in data packs.">
<meta name="og:title" content="<%= htmlWebpackPlugin.options.title %>">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="icon" href="/favicon-32.png" sizes="32x32">
<link rel="stylesheet" href="<%= htmlWebpackPlugin.files.publicPath %>styles/global.css">
<link rel="stylesheet" href="<%= htmlWebpackPlugin.files.publicPath %>styles/nodes.css">
</head>
<body>
<div id="app" class="container"></div>
</body>
</html>

View File

@@ -12,12 +12,14 @@
"fields": "Fields",
"github": "GitHub",
"home": "Home",
"import": "Import",
"item-modifier": "Item Modifier",
"language": "Language",
"loot-table": "Loot Table",
"maximize": "Maximize",
"minimize": "Minimize",
"not_found.description": "The page you were looking for does not exist.",
"no_presets": "No presets",
"predicate": "Predicate",
"redo": "Redo",
"reset": "Reset",
@@ -26,7 +28,11 @@
"settings.fields.path": "Context",
"settings.fields.name": "Name",
"share": "Share",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.system": "System",
"title.generator": "%0% Generator",
"title.generator_category": "%0% Generators",
"title.home": "Data Pack Generators",
"presets": "Presets",
"preview": "Visualize",
@@ -34,8 +40,10 @@
"preview.scale": "Scale",
"preview.depth": "Depth",
"preview.width": "Width",
"source_placeholder": "Paste JSON content here",
"undo": "Undo",
"world": "World Settings",
"worldgen": "Worldgen",
"worldgen/biome": "Biome",
"worldgen/carver": "Carver",
"worldgen/feature": "Feature",

1
src/preact.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import JSX = preact.JSX

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,30 @@
:root {
--node-border: #4e4e4e;
--node-background-label: #1b1b1b;
--node-background-input: #272727;
--node-text: #dadada;
--node-selected: #ad9715;
--node-selected-border: #8d7a0d;
--node-add: #487c13;
--node-add-border: #3b6e0c;
--node-remove: #9b341b;
--node-remove-border: #7e1d05;
--node-indent-border: #454749;
--node-popup-background: #0a0a0ae6;
--node-popup-text: #dadada;
--node-popup-text-dimmed: #b4b4b4;
--category-predicate: #306163;
--category-predicate-border: #224849;
--category-predicate-background: #1d3333;
--category-function: #838383;
--category-function-border: #6b6b6b;
--category-function-background: #414141;
--category-pool: #386330;
--category-pool-border: #2e4922;
--category-pool-background: #21331d;
}
:root[data-theme=light] {
--node-border: #bcbfc3;
--node-background-label: #e4e4e4;
--node-background-input: #ffffff;
@@ -24,32 +50,33 @@
--category-pool-background: #b1d6a6;
}
:root[data-theme=dark] {
--node-border: #4e4e4e;
--node-background-label: #1b1b1b;
--node-background-input: #272727;
--node-text: #dadada;
--node-selected: #ad9715;
--node-selected-border: #8d7a0d;
--node-add: #5a961e;
--node-add-border: #3b6e0c;
--node-remove: #b64023;
--node-remove-border: #7e1d05;
--node-indent-border: #454749;
--node-popup-background: #0a0a0ae6;
--node-popup-text: #dadada;
--node-popup-text-dimmed: #b4b4b4;
--category-predicate: #306163;
--category-predicate-border: #224849;
--category-predicate-background: #1d3333;
--category-function: #838383;
--category-function-border: #6b6b6b;
--category-function-background: #414141;
--category-pool: #386330;
--category-pool-border: #2e4922;
--category-pool-background: #21331d;
@media (prefers-color-scheme: light) {
:root[data-theme=system] {
--node-border: #bcbfc3;
--node-background-label: #e4e4e4;
--node-background-input: #ffffff;
--node-text: #000000;
--node-selected: #f0e65e;
--node-selected-border: #b9a327;
--node-add: #9bd464;
--node-add-border: #498d09;
--node-remove: #e76f51;
--node-remove-border: #be4b2e;
--node-indent-border: #b9b9b9;
--node-popup-background: #1f2020e6;
--node-popup-text: #dadada;
--node-popup-text-dimmed: #b4b4b4;
--category-predicate: #65b5b8;
--category-predicate-border: #187e81;
--category-predicate-background: #95c5c7;
--category-function: #979fa7;
--category-function-border: #788086;
--category-function-background: #dce0e4;
--category-pool: #76b865;
--category-pool-border: #398118;
--category-pool-background: #b1d6a6;
}
}
/* Node headers */
.node-header {
@@ -64,7 +91,6 @@
border: 1px solid;
color: var(--node-text);
border-color: var(--node-border);
transition: all var(--style-transition);
}
.node-header > label {
@@ -85,7 +111,8 @@
padding: 0 2px;
}
.node-header > select {
.node-header > select,
.node-header > datalist {
font-size: 18px;
padding-left: 6px;
background-color: var(--node-background-input);
@@ -105,6 +132,12 @@
cursor: pointer;
}
.node-error ~ select:last-child,
.node-error ~ input:last-child,
.node-error ~ input[list]:nth-last-child(2) {
border-color: var(--node-remove) !important;
}
/** Rounded corners */
.node-header > .node-icon {
@@ -143,7 +176,6 @@ button.selected {
.collapse svg {
fill: var(--node-text);
transition: fill var(--style-transition);
}
.collapse.closed,
@@ -163,7 +195,6 @@ button.remove {
position: relative;
top: 2px;
fill: var(--node-text);
transition: fill var(--style-transition);
}
.node-header > button.collapse:last-child,
@@ -187,7 +218,7 @@ button.remove {
border-radius: 6px;
padding: 8px 4px;
position: absolute;
z-index: 1;
z-index: 2;
top: 125%;
left: 50%;
margin-left: -120px;
@@ -204,7 +235,7 @@ button.remove {
border-color: transparent transparent var(--node-popup-background) transparent;
}
.node-icon:hover .icon-popup,
.node-icon svg:hover + .icon-popup,
.node-icon .icon-popup.show {
visibility: visible;
}
@@ -215,14 +246,13 @@ button.remove {
min-width: 34px;
margin-left: 6px;
cursor: pointer;
transition: fill var(--style-transition);
}
.node-icon.node-help svg {
fill: var(--node-border);
}
.node-icon.node-error svg {
.node-icon.node-error svg {
fill: var(--node-remove);
}
@@ -290,7 +320,6 @@ span.menu-item {
.node-body {
border-left: 3px solid var(--node-indent-border);
transition: border-color var(--style-transition);
}
.node-body {
@@ -330,7 +359,6 @@ span.menu-item {
margin-top: 8px;
border: 2px solid var(--node-border);
border-radius: 3px;
transition: background-color var(--style-transition);
}
.node-entry:first-child > .object-node[data-category],
@@ -367,13 +395,14 @@ span.menu-item {
[data-category=predicate] > .node-header > label,
[data-category=predicate] > .node-body > .node > .node-header > label {
background-color: var(--category-predicate) !important;
background-color: var(--category-predicate);
}
[data-category=predicate] > .node-body,
[data-category=predicate] > .node-header > label,
[data-category=predicate] > .node-header > *:not(.selected),
[data-category=predicate] > .node-body > .node > .node-header > *:not(.selected) {
border-color: var(--category-predicate-border) !important;
border-color: var(--category-predicate-border);
}
.node-entry > .node.object-node[data-category=predicate],
@@ -385,13 +414,14 @@ span.menu-item {
[data-category=function] > .node-header > label,
[data-category=function] > .node-body > .node > .node-header > label {
background-color: var(--category-function) !important;
background-color: var(--category-function);
}
[data-category=function] > .node-body,
[data-category=function] > .node-header > label,
[data-category=function] > .node-header > *:not(.selected),
[data-category=function] > .node-body > .node > .node-header > *:not(.selected) {
border-color: var(--category-function-border) !important;
border-color: var(--category-function-border);
}
.node-entry > .node.object-node[data-category=function],
@@ -403,13 +433,14 @@ span.menu-item {
[data-category=pool] > .node-header > label,
[data-category=pool] > .node-body > .node > .node-header > label {
background-color: var(--category-pool) !important;
background-color: var(--category-pool);
}
[data-category=pool] > .node-body,
[data-category=pool] > .node-header > label,
[data-category=pool] > .node-header > *:not(.selected),
[data-category=pool] > .node-body > .node > .node-header > *:not(.selected) {
border-color: var(--category-pool-border) !important;
border-color: var(--category-pool-border);
}
.node-entry > .node.object-node[data-category=pool],

View File

@@ -1,20 +1,25 @@
{
"compilerOptions": {
"target": "es6",
"lib": [
"dom",
"es2017.object",
"es2019"
],
"module": "es6",
"strict": true,
"module": "esnext",
"lib": ["dom","esnext"],
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"baseUrl": "."
"strict": true,
"sourceMap": true,
"skipLibCheck": false,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
},
"include": [
"src",
"./src",
]
}

44
vite.config.js Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
import html from '@rollup/plugin-html'
import config from './src/config.json'
import { env } from 'process'
export default defineConfig({
build: {
sourcemap: true,
rollupOptions: {
plugins: [
html({
fileName: `404.html`,
title: '404',
template: template,
}),
...config.models.map(m => html({
fileName: `${m.id}/index.html`,
title: getTitle(m),
template: template,
})),
],
},
},
json: {
stringify: true,
},
define: {
__MCDATA_MASTER_HASH__: env ? env.mcdata_hash : '',
__VANILLA_DATAPACK_SUMMARY_HASH__: env ? env.vanilla_datapack_summary_hash : '',
},
plugins: [preact()],
})
function getTitle(m) {
const minVersion = Math.max(0, config.versions.findIndex(v => m.minVersion === v.id))
const versions = config.versions.slice(minVersion).map(v => v.id).join(', ')
return `${m.name} Generator${m.category === true ? 's' : ''} Minecraft ${versions}`
}
function template({ files, title }) {
const source = files.html.find(f => f.fileName === 'index.html').source
return source.replace(/<title>.*<\/title>/, `<title>${title}</title>`)
}

View File

@@ -1,69 +0,0 @@
const CopyWebpackPlugin = require('copy-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MergeJsonWebpackPlugin = require("merge-jsons-webpack-plugin");
const webpack = require('webpack');
const config = require('./src/config.json')
module.exports = (env, argv) => ({
entry: './src/app/Router.ts',
devtool: 'source-map',
output: {
path: __dirname + '/dist',
filename: 'js/bundle.js'
},
resolve: {
extensions: ['.ts', '.js']
},
module: {
rules: [
{ test: /\.ts$/, loader: 'ts-loader' }
]
},
plugins: [
new webpack.DefinePlugin({
__MCDATA_MASTER_HASH__: JSON.stringify(env ? env.mcdata_hash : ''),
__VANILLA_DATAPACK_SUMMARY_HASH__: JSON.stringify(env ? env.vanilla_datapack_summary_hash : '')
}),
new CopyWebpackPlugin({
patterns: [
{ from: 'src/styles', to: 'styles' },
{ from: 'src/sitemap.txt', to: 'sitemap.txt' },
{ from: 'src/favicon-32.png', to: 'favicon-32.png' }
]
}),
new MergeJsonWebpackPlugin({
output: {
groupBy: config.languages.map(lang => ({
pattern: `{./src/locales/${lang.code}.json,./node_modules/@mcschema/locales/src/${lang.code}.json}`,
fileName: `./locales/${lang.code}.json`
}))
}
}),
new HtmlWebpackPlugin({
title: 'Data Pack Generators Minecraft 1.15, 1.16, 1.17',
filename: 'index.html',
template: 'src/index.html'
}),
new HtmlWebpackPlugin({
title: 'Data Pack Generators Minecraft 1.15, 1.16, 1.17',
filename: 'settings/fields/index.html',
template: 'src/index.html'
}),
new HtmlWebpackPlugin({
title: 'Data Pack Generators Minecraft 1.15, 1.16, 1.17',
filename: '404.html',
template: 'src/index.html'
}),
...config.models.map(m => new HtmlWebpackPlugin({
title: getTitle(m),
filename: `${m.id}/index.html`,
template: 'src/index.html'
}))
]
})
function getTitle(m) {
const minVersion = Math.max(0, config.versions.findIndex(v => m.minVersion === v.id))
const versions = config.versions.slice(minVersion).map(v => v.id).join(', ')
return `${m.name} Generator${m.category === true ? 's' : ''} Minecraft ${versions}`
}