Compare commits

...

66 Commits

Author SHA1 Message Date
Max Bradbury f135e184e4 Merge branch 'Refactor3' 2020-11-08 21:19:10 +00:00
Max Bradbury 3e7d6eeaa5 include old version 2020-11-08 21:18:51 +00:00
Max Bradbury f6308110be try again 2020-11-08 20:53:52 +00:00
Max Bradbury 2d73963aa0 black and white palette 2020-11-08 20:52:29 +00:00
Max Bradbury 6f8e00130c update deploy script 2020-11-08 20:50:55 +00:00
Max Bradbury b478b1e3ee update deploy script 2020-11-08 20:50:42 +00:00
Max Bradbury 055928eb7b todo 2020-11-08 20:48:04 +00:00
Max Bradbury b3690c4dd7 black and white palette 2020-11-08 20:46:58 +00:00
Max Bradbury da04534fd9 fix crop shit 2020-11-08 20:45:10 +00:00
Max Bradbury 3d1129c613 log 2020-11-08 20:07:48 +00:00
Max Bradbury 3b851975d0 room stats 2020-11-08 20:07:42 +00:00
Max Bradbury afc626bae0 undo skipping crop on 128² 2020-11-08 20:07:26 +00:00
Max Bradbury 6e43249d64 done 2020-11-08 20:06:08 +00:00
Max Bradbury 458604cd1a display number of tiles added 2020-11-08 17:36:24 +00:00
Max Bradbury bb0b970281 bump 2020-11-08 17:36:11 +00:00
Max Bradbury f62202cb74 style header differently 2020-11-08 17:36:00 +00:00
Max Bradbury 745b18dfc0 styling tweaks 2020-11-08 17:35:28 +00:00
Max Bradbury e5a87f854e this would break things 2020-11-08 17:35:08 +00:00
Max Bradbury c2787db422 better load_image errors 2020-11-08 17:34:48 +00:00
Max Bradbury 7d274bb3c2 this function doesn't return anything 2020-11-08 17:33:39 +00:00
Max Bradbury fbe40fb866 dedupe palette function 2020-11-08 17:33:15 +00:00
Max Bradbury 7ff67e51f8 x,y instead of y,x 2020-11-08 16:59:04 +00:00
Max Bradbury 80df4a9a6a better tile names 2020-11-08 15:37:38 +00:00
Max Bradbury e76ff57053 allow auto pagination from image to room 2020-11-08 15:32:18 +00:00
Max Bradbury 0ef2d2acd9 return image size so the client side can determine whether to crop or not 2020-11-08 15:31:57 +00:00
Max Bradbury f9b0f6b6db name room and give tiles appropriate names too 2020-11-08 15:30:39 +00:00
Max Bradbury 8c92b05b74 generate the dang room 2020-11-08 14:54:49 +00:00
Max Bradbury 512f386c25 first attempt at creating room 2020-11-08 12:30:46 +00:00
Max Bradbury 63cb971aca whitespace 2020-11-08 11:23:44 +00:00
Max Bradbury 03ce88e015 descriptive button 2020-11-08 11:23:37 +00:00
Max Bradbury 8a8b861e5d todo 2020-11-08 11:23:17 +00:00
Max Bradbury c1c4fca1db reset function 2020-11-08 10:09:04 +00:00
Max Bradbury e5da032236 remove commented tests 2020-11-08 00:05:48 +00:00
Max Bradbury d04eaf892b move name to top 2020-11-07 21:55:30 +00:00
Max Bradbury daf9b21096 todos 2020-11-07 21:33:14 +00:00
Max Bradbury 34053a1327 add old version 2020-11-07 19:31:44 +00:00
Max Bradbury 21eb632d22 add old version 2020-11-07 19:30:20 +00:00
Max Bradbury 9eb090924a bump 2020-11-07 16:32:47 +00:00
Max Bradbury 9261c41cd8 tidyup 2020-11-07 16:31:09 +00:00
Max Bradbury 6a58e8c003 change the way palettes are handled; support custom palette 2020-11-07 16:30:50 +00:00
Max Bradbury aea19fcd6c bump bitsy-parser version for hex colours 2020-11-07 16:27:58 +00:00
Max Bradbury db3cdf42ff remove extraneous colour index stuff 2020-11-07 16:27:27 +00:00
Max Bradbury 31d7ff52ca done 2020-11-07 13:48:38 +00:00
Max Bradbury 45ef41c366 fix dithering and indexing for image 2020-11-07 13:48:00 +00:00
Max Bradbury 3a8e349aac feedback for loading game 2020-11-07 11:10:27 +00:00
Max Bradbury 080d0853eb bit more logging 2020-11-07 11:00:07 +00:00
Max Bradbury 0150a5ca33 a bit more range on brightness adjustment; top-align room options 2020-11-07 10:58:46 +00:00
Max Bradbury ec44370d7e make cropper a bit bigger so we can scale down instead of having to scale up to 128×128 2020-11-07 10:57:01 +00:00
Max Bradbury 013e1e1c8c resize cropped image (plugin doesn't seem to allow specification of size) 2020-11-07 10:54:52 +00:00
Max Bradbury 37cde1713d remove unused imports; bump 2020-11-06 16:16:17 +00:00
Max Bradbury 0780bc8267 get actual palette; comment broken tests; fix for new bitsy_parser game result 2020-11-06 16:14:47 +00:00
Max Bradbury fd5f141f19 implement colour mapping and dithering properly 2020-11-06 15:33:26 +00:00
Max Bradbury 5916c4af17 update to-do list 2020-11-06 12:50:31 +00:00
Max Bradbury 69a3b482be update palette on selection 2020-11-06 12:47:26 +00:00
Max Bradbury 4bd896f05a brightness adjustment; find closest palette colour; tests 2020-11-06 12:35:37 +00:00
Max Bradbury 03588c4c55 lots of stuff 2020-11-05 18:48:22 +00:00
Max Bradbury 0cb4f3af8d bigger pages; page caption 2020-11-05 09:52:45 +00:00
Max Bradbury 1b3505929b new crop tool 2020-11-05 09:52:14 +00:00
Max Bradbury 6b679c3bbc ignore output css 2020-11-04 16:19:51 +00:00
Max Bradbury ca93a00eda "set palette" function 2020-11-04 16:18:38 +00:00
Max Bradbury 4c958373ba tweaks 2020-11-04 16:11:44 +00:00
Max Bradbury 65257b3886 readme 2020-11-04 15:46:44 +00:00
Max Bradbury 196ec06c88 ignore output html 2020-11-04 15:39:43 +00:00
Max Bradbury 4dbcd6ac66 update with new changes from tilesy 2020-11-04 15:38:43 +00:00
Max Bradbury 8a8743de93 exact version 2020-10-30 23:54:19 +00:00
Max Bradbury bf17c08909 wip (wasm isn't compiling) 2020-10-30 17:50:03 +00:00
36 changed files with 1532 additions and 225 deletions

17
.gitattributes vendored
View File

@ -1,17 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto
# Custom for Visual Studio
*.cs diff=csharp
# Standard to msysgit
*.doc diff=astextplain
*.DOC diff=astextplain
*.docx diff=astextplain
*.DOCX diff=astextplain
*.dot diff=astextplain
*.DOT diff=astextplain
*.pdf diff=astextplain
*.PDF diff=astextplain
*.rtf diff=astextplain
*.RTF diff=astextplain

13
.gitignore vendored
View File

@ -1,6 +1,3 @@
node_modules
package-lock.json
# Windows image file caches
Thumbs.db
ehthumbs.db
@ -53,8 +50,10 @@ Temporary Items
*.zip
itch*
# node
node_modules
# IDE stuff
.idea
.idea
/Cargo.lock
/dist/
/index.html
/includes/style.css

19
Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "pixsy"
version = "0.72.7"
description = "convert images to Bitsy rooms"
authors = ["Max Bradbury <max@tinybird.info>"]
edition = "2018"
license = "MIT"
repository = "https://tinybird.dev/max/image-to-bitsy"
[lib]
crate-type = ["cdylib"]
[dependencies]
base64 = "^0.12.3"
bitsy-parser = "^0.72.5"
image = "^0.23.7"
json = "^0.12.4"
lazy_static = "^1.4.0"
wasm-bindgen = "=0.2.64" # newer versions are bugged...

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Max Bradbury
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# pixsy
convert images to rooms for use in Bitsy

6
TODO.md Normal file
View File

@ -0,0 +1,6 @@
# todo
* tile reuse
* noise reduction (remove lonely pixels)
* implement Atkinson and Bayer dithering options
* fix weird problem with pixels flipping (see test::example)

View File

@ -1 +0,0 @@
theme: jekyll-theme-dinky

View File

@ -1,4 +1,5 @@
#!/usr/bin/env bash
#! /usr/bin/env bash
pug index.pug index.html
pug index.pug
lessc includes/style.less includes/style.css
wasm-pack build --target web

View File

@ -1,4 +1,6 @@
#!/usr/bin/env bash
#! /usr/bin/env bash
zip -r image-to-bitsy.zip readme.md index.html includes/
butler push image-to-bitsy.zip ruin/image-to-bitsy:html
rm -rf dist
mkdir dist
cp -r README.md LICENSE index.html script.js pkg includes old dist
butler push dist ruin/pixsy:html

1
includes/croppie.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,144 +1,133 @@
@font-face {
font-family: 'rubikregular';
src: url('rubik-regular-webfont.woff2') format('woff2'),
url('rubik-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@light: #d3cbd0;
@dark: #594a54;
@accent: #f69f8f; // pink
@background: #57506a;
@page-background: #968eb5;
@accent: #ec6d7d;
@text: #464256;
* {
box-sizing: border-box;
font-family: "rubikregular", sans-serif;
color: @text;
margin: 0 auto 0.5em auto;
text-align: center;
}
html, body {
background-color: @dark;
color: @light;
background-color: @background;
font-size: 3vmin;
margin: 0;
padding: 0;
}
a {
button {
padding: 1em;
white-space: nowrap;
width: 100%;
&.pagination:not(.normal) {
position: absolute;
bottom: 5vmin;
width: auto;
&.prev {
left: 5vmin;
}
&.next, &.start {
right: 5vmin;
}
}
}
header * {
color: @accent;
}
canvas {
image-rendering: -moz-crisp-edges;
image-rendering: pixelated;
}
input[type="color"] {
&[disabled] {
opacity: 0.5;
}
width: 2em;
height: 2em;
h1 {
margin: 0;
padding: 0;
border: none;
background: none;
}
input[type="file"] {
padding: 1em 0;
width: 256px;
h3 {
font-size: 0.9em;
}
input[type="text"], button {
width: 14em;
margin: 1em;
padding: 0.25em;
font-size: 1em;
input {
width: 100%;
text-align: left;
&[type="checkbox"] {
width: auto;
margin-right: 1em;
position: relative;
top: 0.25em;
left: 0.25em;
}
}
table {
margin: 0 auto;
img {
max-width: 100%;
margin: 0;
}
td, th {
width: 50%;
}
p {
font-size: 0.8em;
margin: 0 auto 1em auto;
}
td {
text-align: left;
}
label {
font-size: 0.75em;
font-weight: bold;
}
th {
text-align: right;
}
select {
width: 100%;
}
textarea {
.box256;
font-family: monospace;
height: 15em;
padding: 0.5em;
text-align: left;
width: 100%;
}
textarea, input {
.background {
background-color: @background;
padding: 0.5em;
border-radius: 0.5em;
}
.checkboxes label {
margin-right: 1em;
}
.cropper-tools {
margin-top: 0;
}
.half {
display: inline-block;
text-align: left;
width: 50%;
}
.image-container {
height: 46vh;
text-align: left;
}
.box256 {
height: 256px;
width: 256px;
.page {
height: 80vmin;
width: 80vmin;
background-color: @page-background;
color: @text;
border-radius: 5vmin;
box-shadow: @accent 1vmin 1vmin;
padding: 5vmin;
position: relative;
}
.centre {
margin: 0 auto;
}
.croppie-container {
height: auto;
}
.flex-container {
background-color: @light;
display: flex;
flex-flow: row wrap;
}
// make this just for desktop view?
// put sections in a single column on mobile/etc?
.section {
.centre;
background-color: @light;
color: @dark;
#preview {
width: 256px;
padding-bottom: 1em;
}
#brightness {
width: 256px;
}
#threshold {
width: 150px;
}
#brightness, #threshold {
+ label{
.centre;
}
// todo make this match the croppie slider or vice versa
}
#palettes {
.box256;
overflow-y: scroll;
}
#preview, #room-output {
.centre;
width: 256px;
}
#save {
margin-top: 0;
height: 256px;
image-rendering: pixelated;
image-rendering: crisp-edges;
}

138
index.pug
View File

@ -1,96 +1,96 @@
doctype html
html
html(lang="en-gb")
head
meta(charset="utf-8")
title image to bitsy
// lodash
script(src="includes/lodash.min.js")
// jquery
script(src="includes/jquery.min.js")
// croppie
title pixsy
link(rel="stylesheet" href="includes/style.css")
link(rel="stylesheet" href="includes/croppie.css")
script(src="includes/croppie.js")
// main stuff
link(rel="stylesheet" type="text/css" href="includes/style.css")
script(src="includes/script.js")
script(src="includes/croppie.min.js")
body
header
h1 image-to-bitsy
p convert any image to a #[a(href="https://ledoux.itch.io/bitsy") bitsy] room
h1
| pixsy
//img(alt="pixsy" src="includes/pixsy.png")
p.
#[a(href="https://github.com/synth-ruiner/bitsy-image-to-room") about]
convert images to Bitsy rooms
|
#[a(href="./old/") old version]
|
please contact me if you have any issues:
#[a(href="https://twitter.com/synth_ruiner") twitter],
#[a(href="mailto:max@tinybird.info") email]
.flex-container
#game-data.section
|
#[a(href="https://twitter.com/synth_ruiner") twitter]
.pages
.page#start
button.normal.pagination.next#new create a new bitsy game
button.normal.pagination.next#load load an existing bitsy game
.page.game-data
h2 game data
textarea#bitsy-data(placeholder="Bitsy data or html")
include includes/default.bitsy
input#game(type="file" autocomplete="off")
br
p
input.game-data(type="file")
p paste or upload your game data (or html) here
p (maybe make a backup first)
textarea#game-data(
placeholder="Paste your game data here or use the file chooser button above"
autocomplete="off"
)
#image.section
button.pagination.prev previous
button.pagination.next#game-data-next(disabled=true) next
.page.image#page-image
h2 image
#croppie
.image-container
input#image(type="file" accept="image/*")
#crop(style="display: none;")
input#imageUpload(type="file" accepts="image/*")
button.pagination.prev previous
button.pagination.next#image-next(disabled=true) next
.page.room#page-room
h2 room
#palette.section
h2 palette
table
tbody
tr
td(style="width: 60%")
img#preview(alt="preview")
br
form#palettes
table
tbody
label
| brightness
input#brightness(type="range" min=-96 max=96 value=0)
td(style="vertical-align: top;")
label
| name (optional)
input#room-name(type="text" placeholder="e.g. 'bedroom'" autocomplete="off")
#crop.section
h2 preview
label
| palette
select#palette
canvas#preview(width=128, height=128)
#new-palette(style="display: none;")
.half
input#colour-background(type="color" value="#000000")
.half
input#colour-foreground(type="color" value="#ffffff")
input#brightness(type="range" min=-255 max=255 value=0)
label
input#dither(type="checkbox" checked=true)
| dither
br
label(for="brightness") brightness
button.pagination.prev#back-to-image previous
button.pagination.next#room-next add room
.page.download
p#added
//- to do
input#dithering(type="checkbox")
label(for="dithering") dithering
h2 download
//- to do
input#smoothing(type="checkbox")
label(for="smoothing") smoothing
#output.section
h2 output
canvas#room-output(width=128, height=128)
label#never never
input#threshold(type="range" min=0 max=64 value=64)
label#always always
br
label(for="threshold") create new tiles
textarea#output(autocomplete="off")
br
input#roomName(type="text", placeholder="room name")
button#download download
button#save write to game data
//- to do
//-label favour broad strokes | favour details
br
//-button Download
button.pagination.prev#add add another image
button.pagination.start#reset start again
script(type="module")
include script.js

250
old/includes/croppie.css Normal file
View File

@ -0,0 +1,250 @@
.croppie-container {
width: 100%;
height: 100%;
}
.croppie-container .cr-image {
z-index: -1;
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
max-height: none;
max-width: none;
}
.croppie-container .cr-boundary {
position: relative;
overflow: hidden;
margin: 0 auto;
z-index: 1;
width: 100%;
height: 100%;
}
.croppie-container .cr-viewport,
.croppie-container .cr-resizer {
position: absolute;
border: 2px solid #fff;
margin: auto;
top: 0;
bottom: 0;
right: 0;
left: 0;
box-shadow: 0 0 2000px 2000px rgba(0, 0, 0, 0.5);
z-index: 0;
}
.croppie-container .cr-resizer {
z-index: 2;
box-shadow: none;
pointer-events: none;
}
.croppie-container .cr-resizer-vertical,
.croppie-container .cr-resizer-horisontal {
position: absolute;
pointer-events: all;
}
.croppie-container .cr-resizer-vertical::after,
.croppie-container .cr-resizer-horisontal::after {
display: block;
position: absolute;
box-sizing: border-box;
border: 1px solid black;
background: #fff;
width: 10px;
height: 10px;
content: '';
}
.croppie-container .cr-resizer-vertical {
bottom: -5px;
cursor: row-resize;
width: 100%;
height: 10px;
}
.croppie-container .cr-resizer-vertical::after {
left: 50%;
margin-left: -5px;
}
.croppie-container .cr-resizer-horisontal {
right: -5px;
cursor: col-resize;
width: 10px;
height: 100%;
}
.croppie-container .cr-resizer-horisontal::after {
top: 50%;
margin-top: -5px;
}
.croppie-container .cr-original-image {
display: none;
}
.croppie-container .cr-vp-circle {
border-radius: 50%;
}
.croppie-container .cr-overlay {
z-index: 1;
position: absolute;
cursor: move;
touch-action: none;
}
.croppie-container .cr-slider-wrap {
width: 75%;
margin: 15px auto;
text-align: center;
}
.croppie-result {
position: relative;
overflow: hidden;
}
.croppie-result img {
position: absolute;
}
.croppie-container .cr-image,
.croppie-container .cr-overlay,
.croppie-container .cr-viewport {
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
/*************************************/
/***** STYLING RANGE INPUT ***********/
/*************************************/
/*http://brennaobrien.com/blog/2014/05/style-input-type-range-in-every-browser.html */
/*************************************/
.cr-slider {
-webkit-appearance: none;
/*removes default webkit styles*/
/*border: 1px solid white; *//*fix for FF unable to apply focus style bug */
width: 300px;
/*required for proper track sizing in FF*/
max-width: 100%;
padding-top: 8px;
padding-bottom: 8px;
background-color: transparent;
}
.cr-slider::-webkit-slider-runnable-track {
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.5);
border: 0;
border-radius: 3px;
}
.cr-slider::-webkit-slider-thumb {
-webkit-appearance: none;
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ddd;
margin-top: -6px;
}
.cr-slider:focus {
outline: none;
}
/*
.cr-slider:focus::-webkit-slider-runnable-track {
background: #ccc;
}
*/
.cr-slider::-moz-range-track {
width: 100%;
height: 3px;
background: rgba(0, 0, 0, 0.5);
border: 0;
border-radius: 3px;
}
.cr-slider::-moz-range-thumb {
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ddd;
margin-top: -6px;
}
/*hide the outline behind the border*/
.cr-slider:-moz-focusring {
outline: 1px solid white;
outline-offset: -1px;
}
.cr-slider::-ms-track {
width: 100%;
height: 5px;
background: transparent;
/*remove bg colour from the track, we'll use ms-fill-lower and ms-fill-upper instead */
border-color: transparent;/*leave room for the larger thumb to overflow with a transparent border */
border-width: 6px 0;
color: transparent;/*remove default tick marks*/
}
.cr-slider::-ms-fill-lower {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.cr-slider::-ms-fill-upper {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
}
.cr-slider::-ms-thumb {
border: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: #ddd;
margin-top:1px;
}
.cr-slider:focus::-ms-fill-lower {
background: rgba(0, 0, 0, 0.5);
}
.cr-slider:focus::-ms-fill-upper {
background: rgba(0, 0, 0, 0.5);
}
/*******************************************/
/***********************************/
/* Rotation Tools */
/***********************************/
.cr-rotate-controls {
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
.cr-rotate-controls button {
border: 0;
background: none;
}
.cr-rotate-controls i:before {
display: inline-block;
font-style: normal;
font-weight: 900;
font-size: 22px;
}
.cr-rotate-l i:before {
content: '↺';
}
.cr-rotate-r i:before {
content: '↻';
}

View File

@ -100,7 +100,7 @@ $(document).ready(function() {
}
function handleBitsyGameData() {
let input = $bitsyData.val();
let input = $('#bitsy-data').val();
if ( ! input) {
return;
@ -488,7 +488,7 @@ $(document).ready(function() {
});
$('#save').on('click touchend', function() {
let $textArea = $('textarea');
$textArea = $('textarea');
let newGameData = $textArea.val();

BIN
old/includes/snowy-owls.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

144
old/includes/style.less Normal file
View File

@ -0,0 +1,144 @@
@font-face {
font-family: 'rubikregular';
src: url('rubik-regular-webfont.woff2') format('woff2'),
url('rubik-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@light: #d3cbd0;
@dark: #594a54;
@accent: #f69f8f; // pink
* {
box-sizing: border-box;
font-family: "rubikregular", sans-serif;
text-align: center;
}
html, body {
background-color: @dark;
color: @light;
}
a {
color: @accent;
}
canvas {
image-rendering: -moz-crisp-edges;
image-rendering: pixelated;
}
input[type="color"] {
&[disabled] {
opacity: 0.5;
}
width: 2em;
height: 2em;
margin: 0;
padding: 0;
border: none;
background: none;
}
input[type="file"] {
padding: 1em 0;
width: 256px;
}
input[type="text"], button {
width: 14em;
margin: 1em;
padding: 0.25em;
font-size: 1em;
}
table {
margin: 0 auto;
td, th {
width: 50%;
}
td {
text-align: left;
}
th {
text-align: right;
}
}
textarea {
.box256;
font-family: monospace;
}
textarea, input {
text-align: left;
}
.box256 {
height: 256px;
width: 256px;
}
.centre {
margin: 0 auto;
}
.croppie-container {
height: auto;
}
.flex-container {
background-color: @light;
display: flex;
flex-flow: row wrap;
}
// make this just for desktop view?
// put sections in a single column on mobile/etc?
.section {
.centre;
background-color: @light;
color: @dark;
width: 256px;
padding-bottom: 1em;
}
#brightness {
width: 256px;
}
#threshold {
width: 150px;
}
#brightness, #threshold {
+ label{
.centre;
}
// todo make this match the croppie slider or vice versa
}
#palettes {
.box256;
overflow-y: scroll;
}
#preview, #room-output {
.centre;
width: 256px;
}
#save {
margin-top: 0;
}

View File

@ -1,14 +0,0 @@
{
"name": "bitsy-image-to-room",
"description": "Tool to convert images to Bitsy rooms. Can work as an offline web application",
"version": "0.0.1",
"devDependencies": {
"pug": "latest",
"pug-cli": "latest",
"less": "latest"
},
"dependencies": {
"croppie": "^2.5.1",
"jquery": "^3.5.0"
}
}

273
script.js Normal file
View File

@ -0,0 +1,273 @@
import init, {
add_room,
get_palettes,
get_preview,
load_image,
load_game,
load_default_game,
output,
set_brightness,
set_dither,
set_palette,
set_room_name,
} from './pkg/pixsy.js';
if (typeof WebAssembly !== "object") {
window.location = "./old/"
}
// stolen from https://ourcodeworld.com/articles/read/189/how-to-create-a-file-and-generate-a-download-with-javascript-in-the-browser-without-a-server
function download(filename, text) {
let element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function el(id) {
return document.getElementById(id);
}
function pagination(e) {
const parent = e.target.parentNode;
parent.style.display = "none";
if (e.target.classList.contains("next")) {
parent.nextSibling.style.display = "block";
} else if (e.target.classList.contains("prev")) {
parent.previousSibling.style.display = "block";
} else if (e.target.classList.contains("start")) {
document.getElementById("start").style.display = "block";
}
}
function readFile(input, callback, type = "text") {
if (input.files && input.files[0]) {
let reader = new FileReader();
reader.onload = callback;
if (type === "text") {
reader.readAsText(input.files[0]);
} else {
reader.readAsDataURL(input.files[0]);
}
}
}
async function run() {
await init();
const buttonAddImage = el("add");
const buttonBackToImage = el("back-to-image");
const buttonDownload = el("download");
const buttonGameDataProceed = el("game-data-next");
const buttonImageProceed = el("image-next");
const buttonRoomProceed = el("room-next");
const buttonLoadGame = el("load");
const buttonNewGame = el("new");
const buttonReset = el("reset");
const checkboxDither = el("dither");
const divCroppie = el("crop");
const divNewPalette = el("new-palette");
const inputBrightness = el("brightness");
const inputColourBackground = el("colour-background");
const inputColourForeground = el("colour-foreground");
const inputRoomName = el("room-name");
const selectPalette = el("palette");
const textareaGameDataInput = el("game-data");
const textareaGameDataOutput = el("output");
const croppie = new Croppie(divCroppie, {
viewport: {width: 128, height: 128, type: 'square'},
boundary: {width: 256, height: 256},
enableZoom: true,
});
// hide all pages except start page
for (let page of document.getElementsByClassName('page')) {
page.style.display = "none";
}
el("start").style.display = "block";
for (let pageButton of document.getElementsByClassName("pagination")) {
pageButton.addEventListener('click', pagination);
pageButton.addEventListener('touchend', pagination);
}
// croppie needs to be on screen to work;
// halt pagination until we're finished gathering the results
buttonImageProceed.removeEventListener("click", pagination);
function new_game() {
load_default_game();
textareaGameDataInput.value = output();
checkGameData();
// we don't need to look at the default game data, so skip ahead to the image page
buttonGameDataProceed.click();
}
function clear_game() {
textareaGameDataInput.value = "";
checkGameData();
}
buttonNewGame.addEventListener("click", new_game);
buttonNewGame.addEventListener("touchend", new_game);
buttonLoadGame.addEventListener("click", clear_game);
buttonLoadGame.addEventListener("touchend", clear_game);
// handle game data and image
el("game").addEventListener("change", function() {
readFile(this, function (e) {
textareaGameDataInput.value = e.target.result;
console.log(load_game(e.target.result));
checkGameData();
}, "text");
});
function setPaletteDropdown() {
const palettes = JSON.parse(get_palettes());
console.debug(palettes);
selectPalette.innerHTML = "";
palettes.push({
id: "NEW_PALETTE",
name: "new palette"
});
for (let palette of palettes) {
let option = document.createElement("option");
option.value = palette.id;
option.innerText = palette.name;
selectPalette.appendChild(option);
}
}
function checkGameData() {
if (textareaGameDataInput.value.length > 0) {
buttonGameDataProceed.removeAttribute("disabled");
setPaletteDropdown();
} else {
buttonGameDataProceed.setAttribute("disabled", "disabled");
}
}
textareaGameDataInput.addEventListener("change", checkGameData);
textareaGameDataInput.addEventListener("keyup", checkGameData);
checkGameData();
el('image').addEventListener('change', function () {
readFile(this, function (e) {
croppie.bind({url: e.target.result, zoom: 0});
divCroppie.style.display = "block";
buttonImageProceed.removeAttribute("disabled");
}, "image");
});
function loadPreview() {
el("preview").setAttribute("src", get_preview());
}
function handleImage() {
croppie.result({
type: "base64",
size: "viewport",
format: "png",
}).then((result) => {
console.log("Loading image: " + load_image(result));
el("page-image").style.display = "none";
el("page-room" ).style.display = "block";
loadPreview();
});
}
buttonImageProceed.addEventListener("click", handleImage);
buttonImageProceed.addEventListener("touchend", handleImage);
selectPalette.addEventListener("change", () => {
set_palette(selectPalette.value, inputColourBackground.value, inputColourForeground.value);
if (selectPalette.value === "NEW_PALETTE") {
divNewPalette.style.display = "block";
} else {
divNewPalette.style.display = "none";
}
loadPreview();
});
function updateCustomPalette() {
set_palette(selectPalette.value, inputColourBackground.value, inputColourForeground.value);
loadPreview();
}
inputColourForeground.addEventListener("change", updateCustomPalette);
inputColourBackground.addEventListener("change", updateCustomPalette);
checkboxDither.addEventListener("change", () => {
set_dither(checkboxDither.checked);
loadPreview();
});
inputRoomName.addEventListener("change", () => {
set_room_name(inputRoomName.value);
});
inputBrightness.addEventListener("input", () => {
set_brightness(inputBrightness.value);
loadPreview();
});
function addRoom() {
el("added").innerText = add_room();
textareaGameDataOutput.value = output();
}
buttonRoomProceed.addEventListener("click", addRoom);
buttonRoomProceed.addEventListener("touchend", addRoom);
function handleDownload() {
download("output.bitsy", textareaGameDataOutput.value);
}
buttonDownload.addEventListener("click", handleDownload);
buttonDownload.addEventListener("touchend", handleDownload);
function addImage() {
textareaGameDataInput.value = textareaGameDataOutput.value;
textareaGameDataOutput.value = "";
buttonBackToImage.click();
}
buttonAddImage.addEventListener("click", addImage);
buttonAddImage.addEventListener("touchend", addImage);
// would it be easier just to reload the page? lol
function reset() {
clear_game();
// todo clear file inputs
inputBrightness.value = 0;
inputRoomName.value = "";
selectPalette.innerHTML = "";
divNewPalette.style.display = "none";
inputColourBackground.value = "#000000";
inputColourForeground.value = "#ffffff";
checkboxDither.checked = true;
}
buttonReset.addEventListener("click", reset);
buttonReset.addEventListener("touchend", reset);
}
run();

74
src/colour_map.rs Normal file
View File

@ -0,0 +1,74 @@
use image::Rgba;
#[derive(Clone, Copy)]
pub struct ColourMap {
background: Rgba<u8>,
foreground: Rgba<u8>,
}
impl ColourMap {
pub(crate) fn from(palette: &bitsy_parser::Palette) -> ColourMap {
let background = Rgba::from([
palette.colours[0].red,
palette.colours[0].green,
palette.colours[0].blue,
255,
]);
let foreground = Rgba::from([
palette.colours[1].red,
palette.colours[1].green,
palette.colours[1].blue,
255,
]);
ColourMap { background, foreground }
}
}
fn diff(a: &Rgba<u8>, b:&Rgba<u8>) -> u32 {
let diff_red = (a[0] as i16 - b[0] as i16).abs();
let diff_green= (a[1] as i16 - b[1] as i16).abs();
let diff_blue = (a[2] as i16 - b[2] as i16).abs();
(diff_red + diff_green + diff_blue) as u32
}
impl image::imageops::colorops::ColorMap for ColourMap {
type Color = Rgba<u8>;
#[inline(always)]
fn index_of(&self, color: &Self::Color) -> usize {
let diff_background = diff(color, &self.background);
let diff_foreground = diff(color, &self.foreground);
if diff_foreground <= diff_background { 1 } else { 0 }
}
#[inline(always)]
fn lookup(&self, idx: usize) -> Option<Self::Color> {
match idx {
0 => Some(self.background.into()),
1 => Some(self.foreground.into()),
_ => None,
}
}
/// Indicate NeuQuant implements `lookup`.
fn has_lookup(&self) -> bool {
true
}
#[inline(always)]
fn map_color(&self, color: &mut Self::Color) {
let closest = match self.index_of(color) {
1 => self.foreground,
_ => self.background,
};
color[0] = closest[0];
color[1] = closest[1];
color[2] = closest[2];
color[3] = closest[3];
}
}

395
src/lib.rs Normal file
View File

@ -0,0 +1,395 @@
#![feature(clamp)]
use bitsy_parser::game::Game;
use bitsy_parser::image::Image;
use bitsy_parser::tile::Tile;
use image::{GenericImageView, Pixel, DynamicImage};
use image::imageops::ColorMap;
use image::imageops::FilterType::CatmullRom;
use lazy_static::lazy_static;
use std::sync::Mutex;
use wasm_bindgen::prelude::*;
mod colour_map;
use colour_map::ColourMap;
const SD: u32 = 8;
enum SelectedPalette {
None,
Existing {
id: String,
},
New {
background: bitsy_parser::Colour,
foreground: bitsy_parser::Colour,
}
}
struct State {
game: Option<Game>,
image: Option<DynamicImage>,
room_name: Option<String>,
palette: SelectedPalette,
dither: bool,
brightness: i32,
}
lazy_static! {
static ref STATE: Mutex<State> = Mutex::new(
State {
game: None,
image: None,
room_name: None,
palette: SelectedPalette::None,
dither: true,
brightness: 0,
}
);
}
fn tile_name(prefix: &Option<String>, x: &u32, y: &u32) -> Option<String> {
if let Some(prefix) = prefix {
Some(format!("{} ({},{})", prefix, x, y))
} else {
None
}
}
#[wasm_bindgen]
pub fn load_default_game() {
let mut state = STATE.lock().unwrap();
state.game = Some(bitsy_parser::mock::game_default());
// yes, this will probably always just be "0", but to be safe…
state.palette = SelectedPalette::Existing {
id: bitsy_parser::mock::game_default().palette_ids()[0].clone()
}
}
#[wasm_bindgen]
pub fn load_game(game_data: String) -> String {
let mut state = STATE.lock().unwrap();
let result = Game::from(game_data);
match result {
Ok((game, _errs)) => {
let palette_id = game.palette_ids()[0].clone();
state.game = Some(game);
state.palette = SelectedPalette::Existing { id: palette_id };
format!("Loaded game")
},
_ => {
state.game = None;
state.palette = SelectedPalette::None;
format!("{}", result.err().unwrap())
}
}
}
#[wasm_bindgen]
pub fn load_image(image_base64: String) -> String {
let mut state = STATE.lock().expect("Couldn't lock application state");
let image_base64: Vec<&str> = image_base64.split("base64,").collect();
if image_base64.len() < 2 {
return format!("Error: Badly-formatted base64: {}", image_base64.join(""));
}
let image_base64 = image_base64[1];
match base64::decode(image_base64) {
Ok(image) => {
match image::load_from_memory(image.as_ref()) {
Ok(image) => {
let size = format!("{}×{}", image.width(), image.height());
// todo get rid of magic numbers! what about Bitsy HD?
let image = image.resize(128, 128, CatmullRom);
state.image = Some(image);
size
},
_ => {
state.image = None;
"Error: Couldn't load image".to_string()
}
}
},
_ => {
state.image = None;
"Error: Couldn't decode image".to_string()
}
}
}
#[wasm_bindgen]
pub fn set_dither(dither: bool) {
let mut state = STATE.lock().unwrap();
state.dither = dither;
}
#[wasm_bindgen]
pub fn set_palette(palette_id: &str, background: String, foreground: String) {
let mut state = STATE.lock().unwrap();
state.palette = match palette_id {
"NEW_PALETTE" => SelectedPalette::New {
background: bitsy_parser::Colour::from_hex(&background).unwrap(),
foreground: bitsy_parser::Colour::from_hex(&foreground).unwrap(),
},
"" => SelectedPalette::None,
_ => SelectedPalette::Existing { id: palette_id.to_string() },
}
}
#[wasm_bindgen]
pub fn set_room_name(room_name: String) {
let mut state = STATE.lock().unwrap();
match room_name.is_empty() {
true => { state.room_name = None },
false => { state.room_name = Some(room_name) },
}
}
#[wasm_bindgen]
pub fn set_brightness(brightness: i32) {
let mut state = STATE.lock().unwrap();
state.brightness = brightness;
}
#[wasm_bindgen]
pub fn get_palettes() -> String {
let state = STATE.lock().unwrap();
let mut palette_objects = json::JsonValue::new_array();
for palette in &state.game.as_ref().unwrap().palettes {
let mut object = json::JsonValue::new_object();
object.insert("id", palette.id.clone()).unwrap();
object.insert(
"name",
palette.name.clone().unwrap_or(
format!("Palette {}", palette.id))
).unwrap();
palette_objects.push(object).unwrap();
}
json::stringify(palette_objects)
}
fn image_to_base64(image: &DynamicImage) -> String {
let mut bytes: Vec<u8> = Vec::new();
image.write_to(&mut bytes, image::ImageOutputFormat::Png).unwrap();
format!("data:image/png;base64,{}", base64::encode(&bytes))
}
fn palette_from(bg: &bitsy_parser::Colour, fg: &bitsy_parser::Colour) -> bitsy_parser::Palette {
bitsy_parser::Palette {
id: "0".to_string(),
name: None,
colours: vec![
bg.clone(), fg.clone(), bitsy_parser::Colour { red: 0, green: 0, blue: 0 }
],
}
}
fn render_preview(state: &State) -> DynamicImage {
let mut buffer = state.image.as_ref().unwrap().clone().into_rgba();
let palette = match &state.palette {
SelectedPalette::None => bitsy_parser::mock::game_default().palettes[0].clone(),
SelectedPalette::Existing { id } => state.game.as_ref().unwrap().get_palette(id).unwrap().clone(),
SelectedPalette::New { background, foreground } => palette_from(background, foreground),
};
let colour_map = crate::ColourMap::from(&palette);
// adjust brightness
let mut buffer = image::imageops::brighten(&mut buffer, state.brightness);
if state.dither {
image::imageops::dither(&mut buffer, &colour_map);
} else {
// just do colour indexing
let indices = image::imageops::colorops::index_colors(&mut buffer, &colour_map);
// todo get rid of magic numbers! what about Bitsy HD?
buffer = image::ImageBuffer::from_fn(128, 128, |x, y| -> image::Rgba<u8> {
let p = indices.get_pixel(x, y);
colour_map
.lookup(p.0[0] as usize)
.expect("indexed colour out-of-range")
.into()
});
}
image::DynamicImage::ImageRgba8(buffer)
}
#[wasm_bindgen]
pub fn get_preview() -> String {
let state = STATE.lock().unwrap();
match &state.image.is_some() {
true => image_to_base64(&render_preview(&state)),
false => "".to_string(),
}
}
#[wasm_bindgen]
pub fn add_room() -> String {
let mut state = STATE.lock().expect("Couldn't lock application state");
if state.game.is_none() {
return "No game data loaded".to_string();
}
match &state.palette {
SelectedPalette::None => { return "No palette selected".to_string(); },
_ => {}
};
let mut game = state.game.clone().unwrap();
if state.image.is_none() {
return "No image loaded".to_string();
}
let palette_id = Some(match &state.palette {
SelectedPalette::None => bitsy_parser::mock::game_default().palettes[0].id.clone(),
SelectedPalette::Existing { id } => id.clone(),
SelectedPalette::New { background, foreground } => {
game.add_palette(palette_from(background, foreground))
},
});
let foreground = &game.palettes
.iter()
.find(|&palette| &palette.id == palette_id.as_ref().unwrap())
.unwrap().colours[1].clone();
let preview = render_preview(&state);
let width = 128;
let height = 128;
let columns = (width as f64 / SD as f64).floor() as u32;
let rows = (height as f64 / SD as f64).floor() as u32;
let mut tile_ids = Vec::new();
let initial_tile_count = game.tiles.len();
for row in 0..rows {
for column in 0..columns {
let mut pixels = Vec::with_capacity(64);
fn colour_match(a: &image::Rgb<u8>, b: &bitsy_parser::Colour) -> u8 {
if a[0] == b.red && a[1] == b.green && a[2] == b.blue { 1 } else { 0 }
};
for y in (row * SD)..((row + 1) * SD) {
for x in (column * SD)..((column + 1) * SD) {
let pixel = preview.get_pixel(x, y).to_rgb();
pixels.push(colour_match(&pixel, foreground));
}
}
let tile = Tile {
// "0" will get overwritten to a new, safe tile ID
id: "0".to_string(),
name: tile_name(&state.room_name, &column, &row),
wall: None,
animation_frames: vec![Image { pixels }],
colour_id: None
};
let tile_id = if game.tiles.contains(&tile) {
game.tiles.iter().find(|&t| t == &tile).unwrap().id.clone()
} else {
game.add_tile(tile)
};
tile_ids.push(tile_id);
}
}
game.add_room(bitsy_parser::Room {
id: "0".to_string(),
palette_id,
name: state.room_name.clone(),
tiles: tile_ids,
items: vec![],
exits: vec![],
endings: vec![],
walls: None
});
game.dedupe_tiles();
let new_tile_count = game.tiles.len();
state.game = Some(game.to_owned());
format!(
"Added room \"{}\" with {} new tiles",
&state.room_name.as_ref().unwrap_or(&"untitled".to_string()),
new_tile_count - initial_tile_count
)
}
#[wasm_bindgen]
pub fn output() -> String {
let state = STATE.lock().unwrap();
match &state.game {
Some(game) => game.to_string(),
None => "No game loaded".to_string(),
}
}
#[cfg(test)]
mod test {
use crate::{add_room, load_image, load_default_game, output, get_preview, set_room_name};
#[test]
fn image_to_base64() {
let image = image::load_from_memory(include_bytes!("test-resources/test.png")).unwrap();
let output = crate::image_to_base64(&image);
let expected = include_str!("test-resources/test.png.base64").trim();
assert_eq!(output, expected);
}
#[test]
fn get_palettes() {
load_default_game();
let output = crate::get_palettes();
let expected = "[{\"id\":\"0\",\"name\":\"blueprint\"}]";
assert_eq!(output, expected);
}
#[test]
fn render_preview() {
load_default_game();
load_image(include_str!("test-resources/colour_input.png.base64").trim().to_string());
let output = get_preview();
let expected = include_str!("test-resources/colour_input.png.base64.greyscale").trim();
assert_eq!(output, expected);
}
#[test]
fn example() {
load_default_game();
load_image(include_str!("test-resources/test.png.base64").trim().to_string());
set_room_name("test".to_string());
println!("add_room(): {}", add_room());
// todo what? why are extraneous pixels appearing in the output tiles?
assert_eq!(output(), include_str!("test-resources/expected.bitsy"));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,159 @@
Write your game's title here
# BITSY VERSION 7.2
! ROOM_FORMAT 1
PAL 0
NAME blueprint
0,82,204
128,159,255
255,255,255
ROOM 0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,a,a,a,a,a,a,a,a,a,a,a,a,a,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,0,0,0,0,0,0,0,0,0,0,0,0,a,0
0,a,a,a,a,a,a,a,a,a,a,a,a,a,a,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
NAME example room
PAL 0
ROOM 1
a,a,1,1,2,2,3,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
NAME test
PAL 0
TIL a
11111111
10000001
10000001
10011001
10011001
10000001
10000001
11111111
NAME block
TIL 1
00011000
00111000
00011000
00011000
00011000
00011000
00011000
00111100
NAME test (2,0)
TIL 2
00111100
01100110
01100110
00001100
00011000
00110000
01100000
01111110
NAME test (4,0)
TIL 3
00111100
01100110
01100110
00001100
00001100
01100110
01100110
00111100
NAME test (6,0)
SPR A
00011000
00011000
00011000
00111100
01111110
10111101
00100100
00100100
POS 0 4,4
SPR a
00000000
00000000
01010001
01110001
01110010
01111100
00111100
00100100
NAME cat
DLG 0
POS 0 8,12
ITM 0
00000000
00000000
00000000
00111100
01100100
00100100
00011000
00000000
NAME tea
DLG 1
ITM 1
00000000
00111100
00100100
00111100
00010000
00011000
00010000
00011000
NAME key
DLG 2
DLG 0
I'm a cat
NAME cat dialog
DLG 1
You found a nice warm cup of tea
NAME tea dialog
DLG 2
A key! {wvy}What does it open?{wvy}
NAME key dialog
VAR a
42

BIN
src/test-resources/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 B

View File

@ -0,0 +1 @@
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAABHNCSVQICAgIfAhkiAAAAcJJREFUeJzt19FqgzAAQFEz+v+/nD1t2NLWsQgW7jlP2ogVvUYdc865LRhj/C7/7Gr/29678cfDeNxmdZznbtv2vwu2H39cPtr26H/mnNuc8259ZZzXvs7a0dHJdjE+022/sjKFrzh6jKyO89ppM8AZrgqw7C6AMcbbk3s0vmL1BfLZewDHPmIGcOdfZ5z1GbjymfbqEI7u5nf7EMvfjG3bzJVhH/EI4DoCiBNAnADiBBAngDgBxAkgTgBxAogTQJwA4gQQJ4A4AcQJIE4AcQKIE0CcAOIEECeAOAHECSBOAHECiBNAnADiBBAngDgBxAkgTgBxAogTQJwA4gQQJ4A4AcQJIE4AcQKIE0CcAOIEECeAOAHECSBOAHECiBNAnADiBBAngDgBxAkgTgBxAogTQJwA4gQQJ4A4AcQJIE4AcQKIE0CcAOIEECeAOAHECSBOAHECiBNAnADiBBAngDgBxAkgTgBxAogTQJwA4gQQJ4A4AcQJIE4AcQKIE0CcAOIEECeAOAHECSBOAHECiBNAnADiBBAngDgBxAkgTgBxAogTQJwA4gQQJ4A4AcQJIE4AcQKIE0CcAOIEECeAOAHEfQM+SHkFb+/YEwAAAABJRU5ErkJggg==