Public Access
Game of Life page element and built-in page
A new page element runs Conway's Game of Life in an area (the whole display by default), one per page. Start: random dots, one pattern, or the icons whose names start with a text in turn or in random order. Edges: wrap around, dead, or a hidden margin where gliders fly out of view (leftovers that stay put in the margin are cleared, so a glider gun runs cleanly). It restarts when the board is still or repeats within 16 generations and/or after a number of generations, after a hold time (an empty board at once). logic/life holds the board and timing (tested), display/life_player runs the board of the shown page, ticked before each frame; frames are paced like scrolling text. The element reuses existing fields, so the page format stays the same. The Icons tab has a library of 17 LifeWiki patterns (checked with a simulation), and the built-in page "Game of Life" shuffles through the life_ icons. About 12 KB flash, 8 KB RAM. Version 2.17.0, tested on the display over OTA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
948087077d
commit
a91d2550d7
29 files changed
+1402
-31
No files matched your search
@@ -79,7 +79,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
|
||||
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
|
||||
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button></div>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button><button class="s" onclick="peAdd('life')">+ Game of Life</button></div>
|
||||
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type ° for degrees. Entity ids can contain {plex_user} (Home Assistant tab). A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
|
||||
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
|
||||
<h3>Add a font</h3>
|
||||
@@ -130,7 +130,10 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="row" id="ic_imgopts" style="display:none"><label>Threshold <input type="range" id="ic_thr" min="1" max="254" value="128"></label>
|
||||
<label><input type="checkbox" id="ic_inv"> Invert</label><label><input type="checkbox" id="ic_dith"> Dither</label></div>
|
||||
<canvas id="ic_canvas" width="840" height="190"></canvas>
|
||||
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div></section>
|
||||
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div>
|
||||
<div class="card"><h2>Game of Life patterns</h2><div class="grid" id="lifepats"></div>
|
||||
<div class="row"><button class="b" onclick="lifeAdd(LIFEPATS)">Add all</button></div>
|
||||
<p class="hint">Famous patterns from LifeWiki (conwaylife.com), added as icons named life_... A Game of Life element on a page can start with one of them, or go through all icons whose names start with life_ (also the ones you draw yourself).</p></div></section>
|
||||
|
||||
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
||||
<canvas id="editor" width="840" height="190"></canvas>
|
||||
@@ -265,7 +268,9 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
|
||||
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
|
||||
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
|
||||
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
|
||||
(page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
|
||||
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
|
||||
if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
|
||||
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
|
||||
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
|
||||
@@ -292,7 +297,7 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
|
||||
mk('div',{className:'tag',textContent:x.width+' x '+x.height+', '+(by.length?'on '+by.join(', '):'not used'),title:by.join(', ')}),
|
||||
mk('div',{className:'row'},btn('Edit',()=>icOpen(x)),btn('Make bitmap',()=>toBitmap(x.name)),btn('Export',()=>exportItem('icon',x.name)),btn('Delete',()=>delIcon(x.name),'d')));
|
||||
ics.appendChild(d)}
|
||||
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));
|
||||
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));lifeLib();
|
||||
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
|
||||
schedBuild();
|
||||
const fl=$('fontlist');fl.innerHTML='';
|
||||
@@ -360,15 +365,18 @@ async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedul
|
||||
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
|
||||
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
|
||||
// page editor: the ESP32 draws the preview and returns the element boxes
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar'};
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar',life:'Game of Life'};
|
||||
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
|
||||
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
|
||||
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
|
||||
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
|
||||
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
|
||||
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
|
||||
function newEl(t){const e={type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
|
||||
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:'',
|
||||
bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0}}
|
||||
bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0};
|
||||
// Game of Life (src/esp32/logic/page.h): format = edges, factor = density, scroll_speed = generations/s, bar_max = hold s
|
||||
if(t=='life')Object.assign(e,{bar_width:84,bar_height:19,format:2,start:'random',factor:30,scroll_speed:4,auto_unit:true,generations:0,bar_max:5,text:'life_'});
|
||||
return e}
|
||||
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
|
||||
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
|
||||
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
|
||||
@@ -432,6 +440,18 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
const pc=mk('input',{type:'checkbox',checked:e.format==1});pc.onchange=()=>{e.format=pc.checked?1:0;if(pc.checked&&same(e.font,'Normal'))e.font='Small';peElems();pePreview()};
|
||||
row(mk('label',{},pc,' percentage on top (inverts the bar below it; only shown when it fits inside, Small needs a height of 7 with frame)'));
|
||||
rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))}
|
||||
if(e.type=='life'){const rot=e.start=='rotate'||e.start=='shuffle',pat=inp(e,'text','text',{size:rot?10:20,maxLength:rot?23:24,placeholder:rot?'life_':'life_glider'});
|
||||
if(!rot)pat.setAttribute('list','bmnames');const n=iconList.filter(x=>x.name.toLowerCase().startsWith((e.text||'').toLowerCase())).length;
|
||||
row(field('Start',sel(e,'start',[['random','random dots'],['pattern','one pattern'],['rotate','patterns in turn'],['shuffle','patterns in random order']],peElems)),
|
||||
...(e.start=='random'?[field('Density',inp(e,'factor','number',{style:'width:4em',min:1,max:100})),'%']:[]),
|
||||
...(e.start=='pattern'?[field('Pattern',pat)]:[]),...(rot?[field('Icons whose names start with',pat),mk('span',{className:'hint',textContent:'('+n+' now)'})]:[]),
|
||||
...(e.start!='random'?[chk(e,'absolute','at a random place (otherwise in the middle)')]:[]));
|
||||
row(field('Edges',sel(e,'format',[[0,'wrap around'],[1,'dead (nothing outside)'],[2,'hidden margin (gliders fly away)']])),
|
||||
field('Speed',inpf(e,'scroll_speed',{style:'width:5em',min:0.1,max:20})),'generations per second',
|
||||
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})));
|
||||
row(chk(e,'auto_unit','restart when it stops changing (still or repeating)'),field('or after',inp(e,'generations','number',{style:'width:6em',min:0})),'generations (0: never);',
|
||||
field('first keep running',inp(e,'bar_max','number',{style:'width:4em',min:0,max:3600})),'s');
|
||||
rows.push(mk('p',{className:'hint',textContent:'Patterns are icons: add famous ones in the Icons tab (Game of Life patterns) or draw your own. Wrap around: what leaves one side comes back on the other. Hidden margin: the world is larger than the area, so gliders fly out of view. A new generation is sent once the dots of the last one have flipped, so a busy board can be slower than the speed. The preview shows the running board while this page is shown, otherwise its start.'}))}
|
||||
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
|
||||
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
|
||||
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
|
||||
@@ -440,9 +460,11 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
|
||||
inpf(e,'scroll_speed',{style:'width:5em',min:0.05,max:30}),'dots per second (0.25: one every 4 s)')}
|
||||
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),...(e.format==1?[field('Font',sel(e,'font',fonts))]:[]),ovl);
|
||||
else if(e.type=='life')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
|
||||
else row(...pos,ovl);
|
||||
box.append(...rows);d.append(box)})}
|
||||
function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
|
||||
function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}
|
||||
if(t=='life'&&pe.page.elements.some(e=>e.type=='life')){msg('One Game of Life per page',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
|
||||
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
|
||||
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
|
||||
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
|
||||
@@ -608,6 +630,26 @@ function imgBits(img,w,h,thr,inv,dith,fit){const c=document.createElement('canva
|
||||
if(dith){const e=l[i]-o;if(x+1<w)l[i+1]+=e*7/16;if(y+1<h){if(x>0)l[i+w-1]+=e*3/16;l[i+w]+=e*5/16;if(x+1<w)l[i+w+1]+=e/16}}}
|
||||
return b}
|
||||
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
|
||||
// Game of Life patterns from LifeWiki (checked with a simulation): icon name, what it does, RLE
|
||||
const LIFEPATS=[['life_glider','flies diagonally','bo$2bo$3o!'],['life_lwss','lightweight spaceship','bo2bo$o4b$o3bo$4o!'],
|
||||
['life_mwss','middleweight spaceship','3bo2b$bo3bo$o5b$o4bo$5o!'],['life_hwss','heavyweight spaceship','3b2o2b$bo4bo$o6b$o5bo$6o!'],
|
||||
['life_copperhead','slow spaceship (c/10)','3b2obo$o6bo$o2bo3b2o$b2o5bob2o$b2o5bob2o$o2bo3b2o$o6bo$3b2obo!'],
|
||||
['life_gosper_gun','glider gun (period 30)','24bo$22bobo$12b2o6b2o12b2o$11bo3bo4b2o12b2o$2o8bo5bo3b2o$2o8bo3bob2o4bobo$10bo5bo7bo$11bo3bo$12b2o!'],
|
||||
['life_pulsar','oscillator (period 3)','2b3o3b3o2$o4bobo4bo$o4bobo4bo$o4bobo4bo$2b3o3b3o2$2b3o3b3o$o4bobo4bo$o4bobo4bo$o4bobo4bo2$2b3o3b3o!'],
|
||||
['life_pentadecathlon','oscillator (period 15)','2bo4bo$2ob4ob2o$2bo4bo!'],['life_queen_bee','shuttle (period 30)','9bo$7bobo$6bobo$2o3bo2bo11b2o$2o4bobo11b2o$7bobo$9bo!'],
|
||||
['life_figure_eight','oscillator (period 8)','3o$3o$3o$3b3o$3b3o$3b3o!'],['life_kok_galaxy','oscillator (period 8)','6ob2o$6ob2o$7b2o$2o5b2o$2o5b2o$2o5b2o$2o$2ob6o$2ob6o!'],
|
||||
['life_beacon','oscillator (period 2)','2o$o$3bo$2b2o!'],['life_r_pentomino','chaos for 1103 generations','b2o$2o$bo!'],
|
||||
['life_acorn','chaos for 5206 generations','bo$3bo$2o2b3o!'],['life_diehard','vanishes after 130 generations','6bo$2o$bo3b3o!'],
|
||||
['life_thunderbird','settles after 243 generations','3o2$bo$bo$bo!'],['life_pi_heptomino','settles after 173 generations','3o$obo$obo!']];
|
||||
function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>='0'&&c<='9'){n+=c;continue}const k=+n||1;n='';
|
||||
if(c=='b')x+=k;else if(c=='o'){for(let i=0;i<k;i++)p.push([x+i,y]);x+=k;w=Math.max(w,x)}else if(c=='$'){y+=k;x=0}else if(c=='!')break}
|
||||
const h=y+1,b=new Uint8Array(w*h);p.forEach(([a,c])=>b[c*w+a]=1);return{w,h,bits:b}}
|
||||
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
|
||||
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
|
||||
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
|
||||
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
|
||||
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}}
|
||||
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
|
||||
// icon editor: its own size, drawn like the bitmap editor
|
||||
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
|
||||
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
|
||||
|
||||
Reference in new issue
Block a user