diff --git a/src/common/ecs/ecs.ts b/src/common/ecs/ecs.ts new file mode 100644 index 00000000..53174c55 --- /dev/null +++ b/src/common/ecs/ecs.ts @@ -0,0 +1,672 @@ + +// entity scopes contain entities, and are nested +// also contain segments (code, bss, rodata) +// components and systems are global +// component fields are stored in arrays, range of entities, can be bit-packed +// some values can be constant, are stored in rodata (or loaded immediate) +// +// systems receive and send events, execute code on entities +// systems are generated on a per-scope basis +// system queries can only contain entities from self and parent scopes +// starting from the 'init' event walk the event tree +// include systems that have at least 1 entity in scope (except init?) +// +// when entering scope, entities are initialized (zero or init w/ data) +// to change scope, fire event w/ scope name +// - how to handle bank-switching? + +export interface Entity { + id: number; + etype: EntityArchetype; + consts: {[name: string]: DataValue}; +} + +export interface EntityConst { + component: ComponentType; + name: string; + value: DataValue; +} + +export interface EntityArchetype { + components: ComponentType[]; +} + +export interface ComponentType { + name: string; + fields: DataField[]; + optional?: boolean; +} + +export interface Query { + include: string[]; + listen?: string[]; + exclude?: string[]; + updates?: string[]; +} + +export interface System { + name: string; + query: Query; + tempbytes?: number; + actions: CodeFragment[]; + emits?: string[]; + live?: EntityArchetype[] | null; +} + +export interface CodeFragment { + text: string; + event: string; + iterate: 'once' | 'each' +} + +export type DataValue = number | boolean | Uint8Array; + +export type DataField = { name: string } & DataType; + +export type DataType = IntType | ArrayType | RefType; + +export interface IntType { + dtype: 'int' + lo: number + hi: number +} + +export interface ArrayType { + dtype: 'array' + elem: DataType + index?: DataType +} + +export interface RefType { + dtype: 'ref' + query: Query +} + +interface FieldArray { + component: ComponentType; + field: DataField; + elo: number; + ehi: number; + access?: FieldAccess[]; +} + +interface FieldAccess { + symbol: string; + bit: number; + width: number; +} + +interface ConstByte { + symbol: string; + bitofs: number; +} + +class SourceFileExport { + lines : string[] = []; + + comment(text: string) { + this.lines.push(';' + text); + } + segment(seg: string, segtype: 'rodata' | 'bss') { + if (segtype == 'bss') + this.lines.push(` seg.u ${seg}`); + else + this.lines.push(` seg ${seg}`); + } + label(sym: string) { + this.lines.push(`${sym}:`); + } + byte(b: number | ConstByte | undefined) { + if (b === undefined) { + this.lines.push(` .ds 1`); + } else if (typeof b === 'number') { + if (b < 0 || b > 255) throw new Error(`out of range byte ${b}`); + this.lines.push(` .byte ${b}`) + } else { + this.lines.push(` .byte (${b.symbol} >> ${b.bitofs}) & 0xff`) + } + } + toString() { + return this.lines.join('\n'); + } +} + +class Segment { + symbols: {[sym: string]: number} = {}; + ofs2sym = new Map(); + fieldranges: {[cfname: string]: FieldArray} = {}; + size: number = 0; + initdata: (number | ConstByte | undefined)[] = []; + + allocateBytes(name: string, bytes: number) { + let ofs = this.size; + this.symbols[name] = ofs; + if (!this.ofs2sym.has(ofs)) + this.ofs2sym.set(ofs, []); + this.ofs2sym.get(ofs)?.push(name); + this.size += bytes; + return ofs; + } + // TODO: optimize shared data + allocateInitData(name: string, bytes: Uint8Array) { + let ofs = this.allocateBytes(name, bytes.length); + for (let i=0; i 256 entities? + } + return 0; +} + +const ASM_ITERATE_EACH = ` + ldx #%{elo} +.loop: + %{action} + inx + cpx #%{ehi}+1 + bne .loop +`; + +export class EntityScope { + childScopes : EntityScope[] = []; + entities : Entity[] = []; + bss = new Segment(); + rodata = new Segment(); + code = new Segment(); + componentsInScope = new Set(); + + constructor( + public readonly em: EntityManager, + public readonly name: string, + public readonly parent: EntityScope | undefined + ) { + parent?.childScopes.push(this); + } + newEntity(etype: EntityArchetype) : Entity { + // TODO: add parent ID? lock parent scope? + let id = this.entities.length; + let entity : Entity = {id, etype, consts:{}}; + this.em.archtypes.add(etype); + for (let c of etype.components) { + this.componentsInScope.add(c.name); + } + this.entities.push(entity); + return entity; + } + *iterateFields() { + for (let i=0; i (a.ehi - a.elo + 1) * getPackedFieldSize(a.field)); + let f; + while (f = fields.pop()) { + let name = f.component.name + "_" + f.field.name; + // TODO: doesn't work for packed arrays too well + let bits = getPackedFieldSize(f.field); + // variable size? make it a pointer + if (bits == 0) bits = 16; + let rangelen = (f.ehi - f.elo + 1); + let bytesperelem = Math.ceil(bits/8) * rangelen; + // TODO: packing bits + // TODO: split arrays + f.access = []; + for (let i=0; i fieldrange.elo) { + // more than one element, add an array + // TODO + } + //console.log(cfname, i, v, fieldrange); + //segment.allocateInitData(cfname, ); + } + } + //console.log(segment.initdata) + } + setConstValue(e: Entity, component: ComponentType, fieldName: string, value: DataValue) { + e.consts[component.name + '.' + fieldName] = value; + } + dump(file: SourceFileExport) { + file.segment(`${this.name}_CODE`, 'rodata'); + this.rodata.dump(file); + file.segment(`${this.name}_DATA`, 'bss'); + this.bss.dump(file); + } + generateCode() { + let code = this.generateCodeForEvent('init'); + console.log(code); + } + generateCodeForEvent(event: string): string { + // find systems that respond to event + // and have entities in this scope + let systems = this.getSystems([event]); + if (systems.length == 0) { + console.log(`no system responds to ${event}`); // TODO: warning + } + let s = ''; + //s += `\n; event ${event}\n`; + let emitcode : {[event: string] : string} = {}; + for (let sys of systems) { + if (sys.emits) { + for (let emit of sys.emits) { + //console.log('>', emit); + // TODO: cycles + emitcode[emit] = this.generateCodeForEvent(emit); + //console.log('<', emit, emitcode[emit].length); + } + } + for (let action of sys.actions) { + let code = action.text; + if (action.event == event) { + // TODO: find loops + if (action.iterate == 'each') { + let ents = this.entitiesMatching(sys.query); + console.log(sys.name, action.event, ents); + //frag = this.iterateCode(frag); + } + // TODO: better parser of ${} + for (let [k,v] of Object.entries(emitcode)) { + let frag = v; + code = code.replace(`%{${k}}`, frag); + } + // anything not replaced? + let unused = /\%\{.+?\}/.exec(code); + if (unused) { + //throw new Error(`${sys.name}:${action.event} did not replace ${unused[0]}`); + } + // TODO: check that this happens once? + s += `\n; action ${sys.name}:${action.event}\n`; + s += code; + } + } + } + return s; + } + entitiesMatching(q: Query) { + let result = []; + let atypes = this.em.archetypesMatching(q); + for (let e of this.entities) { + for (let a of atypes) { + if (e.etype == a.etype) + result.push(e); + } + } + return result; + } + getSystems(events: string[]) { + let result = []; + for (let sys of Object.values(this.em.systems)) { + if (this.systemListensTo(sys, events)) { + result.push(sys); + } + } + return result; + } + systemListensTo(sys: System, events: string[]) { + for (let action of sys.actions) { + if (action.event != null && events.includes(action.event)) { + let archs = this.em.archetypesMatching(sys.query); + for (let arch of archs) { + for (let ctype of arch.cmatch) { + if (this.hasComponent(ctype)) { + return true; + } + } + } + } + } + } + hasComponent(ctype: ComponentType) { + return this.componentsInScope.has(ctype.name); + } +} + +export class EntityManager { + archtypes = new Set(); + components : {[name: string]: ComponentType} = {}; + systems : {[name: string]: System} = {}; + scopes : {[name: string]: EntityScope} = {}; + + newScope(name: string, parent?: EntityScope) { + let scope = new EntityScope(this, name, parent); + if (this.scopes[name]) throw new Error(`scope ${name} already defined`); + this.scopes[name] = scope; + return scope; + } + defineComponent(ctype: ComponentType) { + if (this.components[ctype.name]) throw new Error(`component ${name} already defined`); + return this.components[ctype.name] = ctype; + } + defineSystem(system: System) { + if (this.systems[system.name]) throw new Error(`system ${name} already defined`); + this.systems[system.name] = system; + } + componentsMatching(q: Query, etype: EntityArchetype) { + let list = []; + for (let c of etype.components) { + let cname = c.name; + // TODO: 0 includes == all entities? + if (q.include.length == 0 || q.include.includes(cname)) { + if (!q.exclude?.includes(cname)) { + list.push(c); + } + } + } + return list; + } + archetypesMatching(q: Query) { + let result : {etype: EntityArchetype, cmatch: ComponentType[]}[] = []; + this.archtypes.forEach(etype => { + let cmatch = this.componentsMatching(q, etype); + if (cmatch.length > 0) { + result.push({etype, cmatch}); + } + }); + return result; + } + emitCode(root: System) { + } +} + +/// + +const TEMPLATE_INIT = ` +Start: + CLEAN_START + %{start} +` + +const TEMPLATE1 = ` +.NextFrame: + lsr SWCHB ; test Game Reset switch + bcc Start ; reset? + VERTICAL_SYNC + TIMER_SETUP 37 + %{preframe} + TIMER_WAIT + TIMER_SETUP 192 + %{kernel} + TIMER_WAIT + TIMER_SETUP 29 + %{postframe} + TIMER_WAIT + jmp .NextFrame +`; + +const TEMPLATE2 = ` +#ifdef EVENT_joyup + lda #%00100000 ;Up? + bit SWCHA + bne SkipMoveUp + %{joyup} +.SkipMoveUp +#endif +`; + +const TEMPLATE3 = ` + lda %{ypos},x + sec + sbc #1 + bcc .noclip + sta %{ypos},x +.noclip +`; + +const TEMPLATE4_S = ` + lda %{@sprite.bitmap+0} ; bitmap address + sta temp+0 ; temp space + lda %{@sprite.bitmap+1} ; bitmap address + sta temp+1 ; temp space +` + +const TEMPLATE4_K = ` + ldx %{kernel.numlines} ; lines in kernel +LVScan + txa ; X -> A + sec ; set carry for subtract + sbc YPos ; local coordinate + cmp %{sprite.height} ; in sprite? + bcc InSprite ; yes, skip over next + lda #0 ; not in sprite, load 0 +InSprite + tay ; local coord -> Y + lda (temp+0),y ; lookup color + sta WSYNC ; sync w/ scanline + sta GRP0 ; store bitmap + lda (temp+2),y ; lookup color + sta COLUP0 ; store color + dex ; decrement X + bne LVScan ; repeat until 192 lines +`; + +const SET_XPOS = ` + lda %{sprite.xpos} + ldx %(sprite.plyrindex} + jsr SetHorizPos +` + +const SETHORIZPOS = ` +SetHorizPos: subroutine + sta WSYNC ; start a new line + SLEEP 3 + sec ; set carry flag +SetHorizPosLoop: + sbc #15 ; subtract 15 + bcs SetHorizPosLoop ; branch until negative +SetHorizPosAfter: + ASSERT_SAME_PAGE SetHorizPosLoop, SetHorizPosAfter + eor #7 ; calculate fine offset + asl + asl + asl + asl + sta RESP0,x ; fix coarse position + sta HMP0,x ; set fine offset + sta WSYNC +Return: ; for SLEEP macro, etc. + rts ; return to caller +` + +function test() { + let em = new EntityManager(); + + let c_kernel = em.defineComponent({name:'kernel', fields:[ + {name:'lines', dtype:'int', lo:0, hi:255} + ]}) + let c_sprite = em.defineComponent({name:'sprite', fields:[ + {name:'height', dtype:'int', lo:0, hi:255}, + {name:'plyrindex', dtype:'int', lo:0, hi:1}, + {name:'flags', dtype:'int', lo:0, hi:255}, + ]}) + let c_player = em.defineComponent({name:'player', fields:[ + //TODO: optional? + ]}) + let c_hasbitmap = em.defineComponent({name:'hasbitmap', fields:[ + {name:'bitmap', dtype:'ref', query:{include:['bitmap']}}, + ]}) + let c_hascolormap = em.defineComponent({name:'hascolormap', fields:[ + {name:'colormap', dtype:'ref', query:{include:['colormap']}}, + ]}) + let c_bitmap = em.defineComponent({name:'bitmap', fields:[ + {name:'data', dtype:'array', elem:{ dtype:'int', lo:0, hi:255 }} + ]}) + let c_colormap = em.defineComponent({name:'colormap', fields:[ + {name:'data', dtype:'array', elem:{ dtype:'int', lo:0, hi:255 }} + ]}) + let c_xpos = em.defineComponent({name:'xpos', fields:[ + {name:'xpos', dtype:'int', lo:0, hi:255} + ]}) + let c_ypos = em.defineComponent({name:'ypos', fields:[ + {name:'ypos', dtype:'int', lo:0, hi:255} + ]}) + let c_xyvel = em.defineComponent({name:'xyvel', fields:[ + {name:'xvel', dtype:'int', lo:-8, hi:7}, + {name:'yvel', dtype:'int', lo:-8, hi:7} + ]}) + + // init -> [start] -> frameloop + // frameloop -> [preframe] [kernel] [postframe] + + // TODO: where is kernel numlines? + // temp between preframe + frame? + em.defineSystem({ + name:'kernel-simple', + tempbytes:4, + query:{ + include:['sprite','bitmap','colormap','ypos'], + }, + actions:[ + { text:TEMPLATE4_S, event:'preframe', iterate:'once' }, + { text:TEMPLATE4_K, event:'kernel', iterate:'once' }, + ] + }) + em.defineSystem({ + name:'set-xpos', + query:{ + include:['sprite','xpos'] + }, + actions:[ + { text:SET_XPOS, event:'preframe', iterate:'each' }, + //{ text:SETHORIZPOS }, + ] + }) + // TODO: how to have subsystems? maybe need Scopes + // TODO: easy stagger of system update? + // TODO: easy lookup tables + // TODO: how to init? + // https://docs.unity3d.com/Packages/com.unity.entities@0.17/manual/ecs_systems.html + em.defineSystem({ + name:'init', + emits:['start'], + query:{ + include:[], // ??? + }, + actions:[ + { text:TEMPLATE_INIT, event:'init', iterate:'once' } + ] + }) + em.defineSystem({ + name:'frameloop', + emits:['preframe','kernel','postframe'], + query:{ + include:['kernel'], // ??? + }, + actions:[ + { text:TEMPLATE1, event:'start', iterate:'once' } + ] + }) + em.defineSystem({ + name:'joyread', + query:{ + include:['player'] + }, + emits:['joyup','joydown','joyleft','joyright','joybutton'], + actions:[ + { text:TEMPLATE2, event:'postframe', iterate:'each' } + ] + }); + em.defineSystem({ + name:'simple-move', + query:{ + include:['player','xpos','ypos'] + }, + actions:[ + { text:TEMPLATE3, event:'joyup', iterate:'each' } + ] + }); + + let root = em.newScope("Root"); + let scene = em.newScope("Scene", root); + let e_ekernel = root.newEntity({components:[c_kernel]}); + root.setConstValue(e_ekernel, c_kernel, 'lines', 192); + + let e_bitmap0 = root.newEntity({components:[c_bitmap]}); + // TODO: store array sizes? + root.setConstValue(e_bitmap0, c_bitmap, 'data', new Uint8Array([1,2,3,4,5])); + + let e_colormap0 = root.newEntity({components:[c_colormap]}); + root.setConstValue(e_colormap0, c_colormap, 'data', new Uint8Array([1,2,3,4,5])); + + let ea_playerSprite = {components:[c_sprite,c_hasbitmap,c_hascolormap,c_xpos,c_ypos,c_player]}; + let e_player0 = root.newEntity(ea_playerSprite); + let e_player1 = root.newEntity(ea_playerSprite); + + let src = new SourceFileExport(); + root.analyzeEntities(); + root.generateCode(); + root.dump(src); + console.log(src.toString()); +} + +test(); diff --git a/src/common/ecs/tsconfig.json b/src/common/ecs/tsconfig.json new file mode 100644 index 00000000..44c1e47b --- /dev/null +++ b/src/common/ecs/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noImplicitThis": true, + "noImplicitAny": true, + "preserveConstEnums": true, + "alwaysStrict": true, + "strictNullChecks": true, + } +}