850 lines
32 KiB
HTML
Executable File
850 lines
32 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<title>Karel Simulator</title>
|
||
<style>
|
||
:root {
|
||
--bg: #121212;
|
||
--surface: #1e1e1e;
|
||
--border: #2a2a2a;
|
||
--text: #e0e0e0;
|
||
--accent: #0f0;
|
||
--wall: #d32f2f;
|
||
--hover: rgba(255,255,255,0.05);
|
||
--radius: 4px;
|
||
}
|
||
*,*::before,*::after{box-sizing:border-box;}
|
||
body {
|
||
margin:0;padding:20px;
|
||
background:var(--bg);
|
||
color:var(--text);
|
||
font-family:monospace;
|
||
line-height:1.4;
|
||
}
|
||
h1,h2,p,label{color:var(--text);}
|
||
#container,#mapEditorContainer {
|
||
display:flex;gap:20px;
|
||
}
|
||
#leftPane,#mapEditorControls{flex:1;max-width:600px;}
|
||
#rightPane,#editorMap{flex:1;}
|
||
#editor,#console,#editorMap,#map {
|
||
width:100%;
|
||
background:var(--surface);
|
||
border:1px solid var(--border);
|
||
border-radius:var(--radius);
|
||
padding:12px;
|
||
margin-bottom:20px;
|
||
white-space:pre;
|
||
color:var(--text);
|
||
font-family:monospace;
|
||
overflow:auto;
|
||
}
|
||
#editor{height:200px;}
|
||
#console{height:150px;color:var(--accent);white-space:pre-wrap;}
|
||
button,input[type="number"],select {
|
||
background:var(--surface);
|
||
border:1px solid var(--border);
|
||
border-radius:var(--radius);
|
||
color:var(--text);
|
||
padding:8px 16px;
|
||
font:inherit;
|
||
cursor:pointer;
|
||
transition:background 0.2s;
|
||
margin-right:10px;
|
||
}
|
||
button:hover,input[type="number"]:hover,select:hover {
|
||
background:var(--hover);
|
||
}
|
||
button:active{background:var(--border);}
|
||
input[type="file"]{
|
||
background:none;border:none;color:var(--text);
|
||
}
|
||
#map{
|
||
background:var(--surface);
|
||
border:1px solid var(--border);
|
||
color:var(--text);
|
||
}
|
||
#status{display:flex;gap:20px;margin-top:10px;}
|
||
.status-card{
|
||
background:var(--surface);
|
||
border:1px solid var(--border);
|
||
border-radius:var(--radius);
|
||
padding:8px 12px;
|
||
display:flex;align-items:center;
|
||
}
|
||
.status-card .label{color:var(--accent);margin-right:6px;}
|
||
.map-border{color:var(--accent);}
|
||
.cell-content{color:var(--accent);}
|
||
.wall-border{color:var(--wall);}
|
||
/* Editor spacing */
|
||
#mapEditorControls{display:flex;flex-direction:column;gap:16px;}
|
||
#mapEditorControls h2{margin-bottom:0;}
|
||
#mapEditorControls > label,
|
||
#mapEditorControls > input[type="number"],
|
||
#mapEditorControls > select,
|
||
#mapEditorControls > button {
|
||
margin:4px 0;
|
||
}
|
||
.edit-mode-group {
|
||
display:flex;
|
||
flex-direction:column;
|
||
gap:8px;
|
||
}
|
||
.clickable-wall{
|
||
display:inline-block;
|
||
transition:background 0.2s;
|
||
cursor:pointer;
|
||
}
|
||
.clickable-wall:hover{
|
||
background:var(--hover);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Karel Simulator</h1>
|
||
|
||
<!-- Main Simulator View -->
|
||
<div id="container">
|
||
<div id="leftPane">
|
||
<p>
|
||
Write your Karel program below. Parentheses & semicolons mandatory.
|
||
If you’re ever unsure how to use the Simulator,
|
||
<a href="manual.html" target="_blank" style="color:var(--accent);text-decoration:underline;">
|
||
consult the manual
|
||
</a>.
|
||
</p>
|
||
<textarea id="editor">// Example:
|
||
if (frontIsBlocked()) {
|
||
turnLeft();
|
||
} else {
|
||
move();
|
||
}
|
||
</textarea>
|
||
<button id="runButton">Compile & Run</button>
|
||
<button id="stopButton">Stop</button>
|
||
<button id="editMapButton">Edit Map</button>
|
||
<h2>Console Output:</h2>
|
||
<div id="console"></div>
|
||
<h2>Upload Map File:</h2>
|
||
<input type="file" id="mapFileInput" accept=".txt,.kw"/>
|
||
<button id="uploadMapButton">Upload Map</button>
|
||
</div>
|
||
|
||
<div id="rightPane">
|
||
<h2>Karel Map:</h2>
|
||
<div id="map"></div>
|
||
<div id="status"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manual Map Editor -->
|
||
<div id="mapEditorContainer" style="display:none;">
|
||
<div id="mapEditorControls">
|
||
<h2>Manual Map Editor</h2>
|
||
|
||
<label>
|
||
Width:
|
||
<input type="number" id="editWidth" min="3" value="10"/>
|
||
</label>
|
||
<label>
|
||
Height:
|
||
<input type="number" id="editHeight" min="3" value="10"/>
|
||
</label>
|
||
<button id="generateMapButton">Generate Map</button>
|
||
|
||
<div class="edit-mode-group">
|
||
<label><input type="radio" name="editMode" value="wall" checked/> Toggle Wall</label>
|
||
<label><input type="radio" name="editMode" value="beeper"/> Beeper (click to add, Ctrl+click to remove)</label>
|
||
<label><input type="radio" name="editMode" value="karel"/> Set Karel</label>
|
||
</div>
|
||
|
||
<label>
|
||
Karel Dir:
|
||
<select id="karelDirSelect">
|
||
<option value="north">North</option>
|
||
<option value="east">East</option>
|
||
<option value="south">South</option>
|
||
<option value="west">West</option>
|
||
</select>
|
||
</label>
|
||
|
||
<!-- NEW: Beeper Bag Count -->
|
||
<label>
|
||
Beeper Bag:
|
||
<input type="number" id="beeperBagInput" min="0" max="99" value="0"/>
|
||
</label>
|
||
|
||
<button id="importEditorMapButton">Import to Simulator</button>
|
||
<button id="discardEditorMapButton">Discard Changes</button>
|
||
</div>
|
||
<div id="editorMap"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// ───────── Globals & State ─────────
|
||
let uploadedMapData = null,
|
||
currentWorld, currentKarel,
|
||
editorMapData = null,
|
||
STEP_DELAY = 300,
|
||
stopRequested = false,
|
||
editMode = 'wall';
|
||
|
||
function log(msg){
|
||
document.getElementById('console').textContent += msg + '\n';
|
||
}
|
||
function sleep(ms){
|
||
return new Promise(r=>setTimeout(r,ms));
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 1. LEXER
|
||
**********************************************************************/
|
||
class Token { constructor(type,value){ this.type=type; this.value=value; } }
|
||
const TokenType={ IDENTIFIER:'IDENTIFIER',KEYWORD:'KEYWORD',SYMBOL:'SYMBOL',EOF:'EOF' };
|
||
const keywords=new Set(['if','else','while']);
|
||
class Lexer {
|
||
constructor(input){ this.input=input; this.pos=0; }
|
||
skipWhitespace(){
|
||
while(this.pos<this.input.length && /\s/.test(this.input[this.pos]))
|
||
this.pos++;
|
||
}
|
||
nextToken(){
|
||
this.skipWhitespace();
|
||
if(this.pos>=this.input.length) return new Token(TokenType.EOF,'');
|
||
const ch=this.input[this.pos];
|
||
if(/[A-Za-z_]/.test(ch)){
|
||
let start=this.pos;
|
||
while(this.pos<this.input.length && /[A-Za-z0-9_]/.test(this.input[this.pos]))
|
||
this.pos++;
|
||
const w=this.input.slice(start,this.pos);
|
||
return new Token(keywords.has(w)?TokenType.KEYWORD:TokenType.IDENTIFIER,w);
|
||
}
|
||
if('(){};'.includes(ch)){
|
||
this.pos++;
|
||
return new Token(TokenType.SYMBOL,ch);
|
||
}
|
||
this.pos++;
|
||
return this.nextToken();
|
||
}
|
||
tokenize(){
|
||
const tokens=[];
|
||
let tok;
|
||
do{ tok=this.nextToken(); tokens.push(tok); }
|
||
while(tok.type!==TokenType.EOF);
|
||
return tokens;
|
||
}
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 2. PARSER
|
||
**********************************************************************/
|
||
class ProgramNode{ constructor(stmts){ this.statements=stmts; } }
|
||
class ExpressionStatement{ constructor(expr){ this.expr=expr; } }
|
||
class IfStatement{ constructor(cond,thenB,elseB){
|
||
this.condition=cond; this.thenBlock=thenB; this.elseBlock=elseB;
|
||
}}
|
||
class WhileStatement{ constructor(cond,blk){ this.condition=cond; this.block=blk; } }
|
||
class BlockStatement{ constructor(stmts){ this.statements=stmts; } }
|
||
class FunctionCall{ constructor(name){ this.name=name; } }
|
||
|
||
class Parser {
|
||
constructor(tokens){ this.tokens=tokens; this.pos=0; }
|
||
cur(){ return this.tokens[this.pos]; }
|
||
eat(type,val=null){
|
||
const t=this.cur();
|
||
if(t.type!==type||(val&&t.value!==val))
|
||
throw new Error(`Unexpected token: ${t.value}`);
|
||
this.pos++;
|
||
return t;
|
||
}
|
||
parseProgram(){
|
||
const stmts=[];
|
||
while(this.cur().type!==TokenType.EOF){
|
||
stmts.push(this.parseStatement());
|
||
}
|
||
return new ProgramNode(stmts);
|
||
}
|
||
parseStatement(){
|
||
const t=this.cur();
|
||
if(t.type===TokenType.KEYWORD&&t.value==='if') return this.parseIf();
|
||
if(t.type===TokenType.KEYWORD&&t.value==='while') return this.parseWhile();
|
||
if(t.type===TokenType.SYMBOL&&t.value==='{') return this.parseBlock();
|
||
const expr=this.parseExpression();
|
||
this.eat(TokenType.SYMBOL,';');
|
||
return new ExpressionStatement(expr);
|
||
}
|
||
parseIf(){
|
||
this.eat(TokenType.KEYWORD,'if');
|
||
this.eat(TokenType.SYMBOL,'(');
|
||
const cond=this.parseExpression();
|
||
this.eat(TokenType.SYMBOL,')');
|
||
const thenB=this.parseStatement();
|
||
let elseB=null;
|
||
if(this.cur().type===TokenType.KEYWORD&&this.cur().value==='else'){
|
||
this.eat(TokenType.KEYWORD,'else');
|
||
elseB=this.parseStatement();
|
||
}
|
||
return new IfStatement(cond,thenB,elseB);
|
||
}
|
||
parseWhile(){
|
||
this.eat(TokenType.KEYWORD,'while');
|
||
this.eat(TokenType.SYMBOL,'(');
|
||
const cond=this.parseExpression();
|
||
this.eat(TokenType.SYMBOL,')');
|
||
const blk=this.parseStatement();
|
||
return new WhileStatement(cond,blk);
|
||
}
|
||
parseBlock(){
|
||
this.eat(TokenType.SYMBOL,'{');
|
||
const stmts=[];
|
||
while(!(this.cur().type===TokenType.SYMBOL&&this.cur().value==='}')){
|
||
stmts.push(this.parseStatement());
|
||
}
|
||
this.eat(TokenType.SYMBOL,'}');
|
||
return new BlockStatement(stmts);
|
||
}
|
||
parseExpression(){ return this.parseFunctionCall(); }
|
||
parseFunctionCall(){
|
||
const name=this.eat(TokenType.IDENTIFIER).value;
|
||
this.eat(TokenType.SYMBOL,'(');
|
||
this.eat(TokenType.SYMBOL,')');
|
||
return new FunctionCall(name);
|
||
}
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 3. INTERPRETER (async + stop)
|
||
**********************************************************************/
|
||
async function executeProgramAsync(prog,ctx){
|
||
for(const stmt of prog.statements){
|
||
if(stopRequested) throw new Error("Execution stopped by user");
|
||
await executeStatementAsync(stmt,ctx);
|
||
}
|
||
}
|
||
async function executeStatementAsync(stmt,ctx){
|
||
if(stopRequested) throw new Error("Execution stopped by user");
|
||
if(stmt instanceof ExpressionStatement){
|
||
await evaluateExpressionAsync(stmt.expr,ctx);
|
||
} else if(stmt instanceof IfStatement){
|
||
if(await evaluateExpressionAsync(stmt.condition,ctx)){
|
||
await executeStatementAsync(stmt.thenBlock,ctx);
|
||
} else if(stmt.elseBlock){
|
||
await executeStatementAsync(stmt.elseBlock,ctx);
|
||
}
|
||
} else if(stmt instanceof WhileStatement){
|
||
while(await evaluateExpressionAsync(stmt.condition,ctx)){
|
||
if(stopRequested) throw new Error("Execution stopped by user");
|
||
await executeStatementAsync(stmt.block,ctx);
|
||
}
|
||
} else if(stmt instanceof BlockStatement){
|
||
for(const s of stmt.statements){
|
||
if(stopRequested) throw new Error("Execution stopped by user");
|
||
await executeStatementAsync(s,ctx);
|
||
}
|
||
}
|
||
}
|
||
async function evaluateExpressionAsync(expr,ctx){
|
||
if(stopRequested) throw new Error("Execution stopped by user");
|
||
if(expr instanceof FunctionCall){
|
||
const res = executeFunction(expr.name,ctx.karel);
|
||
if(['move','turnLeft','pickBeeper','putBeeper'].includes(expr.name)){
|
||
renderWorld(currentWorld,currentKarel);
|
||
await sleep(STEP_DELAY);
|
||
}
|
||
return res;
|
||
}
|
||
throw new Error('Unknown expression');
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 4. WORLD & KAREL
|
||
**********************************************************************/
|
||
const DIRECTIONS={NORTH:0,EAST:1,SOUTH:2,WEST:3},
|
||
DIRECTION_SYMBOLS=['↑','→','↓','←'];
|
||
class World {
|
||
constructor(w,h){
|
||
this.width=w; this.height=h;
|
||
this.grid=[]; this.walls=[];
|
||
for(let x=0;x<w;x++){
|
||
this.grid[x]=[]; this.walls[x]=[];
|
||
for(let y=0;y<h;y++){
|
||
this.grid[x][y]=0;
|
||
this.walls[x][y]={north:false,east:false,south:false,west:false};
|
||
}
|
||
}
|
||
}
|
||
isWithinBounds(x,y){return x>=0&&x<this.width&&y>=0&&y<this.height;}
|
||
getBeeperCount(x,y){return this.isWithinBounds(x,y)?this.grid[x][y]:0;}
|
||
addBeeper(x,y){if(this.isWithinBounds(x,y))this.grid[x][y]++;}
|
||
removeBeeper(x,y){
|
||
if(this.isWithinBounds(x,y)&&this.grid[x][y]>0){this.grid[x][y]--;return true;}
|
||
return false;
|
||
}
|
||
}
|
||
class Karel {
|
||
constructor(world,x,y,dir){
|
||
this.world=world;this.x=x;this.y=y;this.direction=dir;this.beeperBag=0;
|
||
}
|
||
frontIsClear(){
|
||
const side=['north','east','south','west'][this.direction];
|
||
if(this.world.walls[this.x][this.y][side])return false;
|
||
const [nx,ny]=this.nextPosition(this.direction);
|
||
return this.world.isWithinBounds(nx,ny);
|
||
}
|
||
frontIsBlocked(){return !this.frontIsClear();}
|
||
leftIsClear(){
|
||
const d=(this.direction+3)%4,side=['north','east','south','west'][d];
|
||
if(this.world.walls[this.x][this.y][side])return false;
|
||
const [nx,ny]=this.nextPosition(d);
|
||
return this.world.isWithinBounds(nx,ny);
|
||
}
|
||
leftIsBlocked(){return !this.leftIsClear();}
|
||
rightIsClear(){
|
||
const d=(this.direction+1)%4,side=['north','east','south','west'][d];
|
||
if(this.world.walls[this.x][this.y][side])return false;
|
||
const [nx,ny]=this.nextPosition(d);
|
||
return this.world.isWithinBounds(nx,ny);
|
||
}
|
||
rightIsBlocked(){return !this.rightIsClear();}
|
||
nextToABeeper(){return this.world.getBeeperCount(this.x,this.y)>0;}
|
||
notNextToABeeper(){return !this.nextToABeeper();}
|
||
facingNorth(){return this.direction===DIRECTIONS.NORTH;}
|
||
notFacingNorth(){return !this.facingNorth();}
|
||
facingSouth(){return this.direction===DIRECTIONS.SOUTH;}
|
||
notFacingSouth(){return !this.facingSouth();}
|
||
facingEast(){return this.direction===DIRECTIONS.EAST;}
|
||
notFacingEast(){return !this.facingEast();}
|
||
facingWest(){return this.direction===DIRECTIONS.WEST;}
|
||
notFacingWest(){return !this.facingWest();}
|
||
anyBeepersInBeeperBag(){return this.beeperBag>0;}
|
||
noBeepersInBeeperBag(){return this.beeperBag===0;}
|
||
nextPosition(dir){
|
||
let nx=this.x,ny=this.y;
|
||
if(dir===DIRECTIONS.NORTH)ny++;
|
||
if(dir===DIRECTIONS.EAST) nx++;
|
||
if(dir===DIRECTIONS.SOUTH)ny--;
|
||
if(dir===DIRECTIONS.WEST) nx--;
|
||
return [nx,ny];
|
||
}
|
||
move(){
|
||
if(this.frontIsClear()){
|
||
[this.x,this.y]=this.nextPosition(this.direction);
|
||
log(`Moved to (${this.x}, ${this.y})`);
|
||
} else {
|
||
log("Cannot move; wall ahead.");
|
||
}
|
||
}
|
||
turnLeft(){
|
||
this.direction=(this.direction+3)%4;
|
||
log("Turned left. Now facing "+DIRECTION_SYMBOLS[this.direction]);
|
||
}
|
||
pickBeeper(){
|
||
if(this.world.getBeeperCount(this.x,this.y)>0){
|
||
this.world.removeBeeper(this.x,this.y);
|
||
this.beeperBag++;
|
||
log(`Picked up a beeper. Bag: ${this.beeperBag}`);
|
||
} else {
|
||
log("No beeper to pick up.");
|
||
}
|
||
}
|
||
putBeeper(){
|
||
if(this.beeperBag>0){
|
||
this.world.addBeeper(this.x,this.y);
|
||
this.beeperBag--;
|
||
log(`Placed a beeper. Bag: ${this.beeperBag}`);
|
||
} else {
|
||
log("No beeper in bag.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 5. FUNCTION MAPPING
|
||
**********************************************************************/
|
||
function executeFunction(name,karel){
|
||
switch(name){
|
||
case 'move':return karel.move();
|
||
case 'turnLeft':return karel.turnLeft();
|
||
case 'pickBeeper':return karel.pickBeeper();
|
||
case 'putBeeper':return karel.putBeeper();
|
||
case 'frontIsClear':return karel.frontIsClear();
|
||
case 'frontIsBlocked':return karel.frontIsBlocked();
|
||
case 'leftIsClear':return karel.leftIsClear();
|
||
case 'leftIsBlocked':return karel.leftIsBlocked();
|
||
case 'rightIsClear':return karel.rightIsClear();
|
||
case 'rightIsBlocked':return karel.rightIsBlocked();
|
||
case 'nextToABeeper':return karel.nextToABeeper();
|
||
case 'notNextToABeeper':return karel.notNextToABeeper();
|
||
case 'facingNorth':return karel.facingNorth();
|
||
case 'notFacingNorth':return karel.notFacingNorth();
|
||
case 'facingSouth':return karel.facingSouth();
|
||
case 'notFacingSouth':return karel.notFacingSouth();
|
||
case 'facingEast':return karel.facingEast();
|
||
case 'notFacingEast':return karel.notFacingEast();
|
||
case 'facingWest':return karel.facingWest();
|
||
case 'notFacingWest':return karel.notFacingWest();
|
||
case 'anyBeepersInBeeperBag':return karel.anyBeepersInBeeperBag();
|
||
case 'noBeepersInBeeperBag':return karel.noBeepersInBeeperBag();
|
||
}
|
||
throw new Error(`Unknown function: ${name}`);
|
||
}
|
||
const context={karel:null,callFunction:n=>executeFunction(n,context.karel)};
|
||
|
||
/**********************************************************************
|
||
* 6. WORLD RENDERING
|
||
**********************************************************************/
|
||
function renderWorld(world,karel){
|
||
currentWorld=world;currentKarel=karel;
|
||
const w=world.width,h=world.height,total=w*4+1;
|
||
let out='';
|
||
// top
|
||
for(let i=0;i<total;i++){
|
||
out+= (i===0||i===total-1)
|
||
? '<span class="map-border">+</span>'
|
||
: '<span class="map-border">-</span>';
|
||
}
|
||
out+='<br>';
|
||
// rows
|
||
for(let y=h-1;y>=0;y--){
|
||
let row='';
|
||
for(let x=0;x<w;x++){
|
||
if(x===0) row+='<span class="map-border">|</span>';
|
||
else if(world.walls[x-1][y].east) row+='<span class="wall-border">|</span>';
|
||
else row+=' ';
|
||
let c='.';
|
||
if(karel.x===x&&karel.y===y) c=DIRECTION_SYMBOLS[karel.direction];
|
||
else {
|
||
const bc=world.getBeeperCount(x,y);
|
||
if(bc>0) c=bc<10?''+bc:'?';
|
||
}
|
||
row+=`<span class="cell-content"> ${c} </span>`;
|
||
}
|
||
row+='<span class="map-border">|</span><br>';
|
||
out+=row;
|
||
if(y>0){
|
||
let div='';
|
||
for(let x=0;x<w;x++){
|
||
if(x===0) div+='<span class="map-border">+</span>';
|
||
else div+=' ';
|
||
div+= world.walls[x][y].south
|
||
? '<span class="wall-border">---</span>'
|
||
: '<span class="map-border"> </span>';
|
||
}
|
||
div+='<span class="map-border">+</span><br>';
|
||
out+=div;
|
||
}
|
||
}
|
||
// bottom
|
||
let bot='';
|
||
for(let i=0;i<total;i++){
|
||
bot+= (i===0||i===total-1)
|
||
? '<span class="map-border">+</span>'
|
||
: '<span class="map-border">-</span>';
|
||
}
|
||
bot+='<br>';
|
||
out+=bot;
|
||
document.getElementById('map').innerHTML=out;
|
||
document.getElementById('status').innerHTML=
|
||
`<div class="status-card"><span class="label">Position:</span><span class="value">(${karel.x}, ${karel.y})</span></div>`+
|
||
`<div class="status-card"><span class="label">Beepers in Bag:</span><span class="value">${karel.beeperBag}</span></div>`;
|
||
}
|
||
|
||
/**********************************************************************
|
||
* 7. MANUAL MAP EDITOR
|
||
**********************************************************************/
|
||
function showMain(){
|
||
document.getElementById('container').style.display='flex';
|
||
document.getElementById('mapEditorContainer').style.display='none';
|
||
}
|
||
function showEditor(){
|
||
document.getElementById('container').style.display='none';
|
||
document.getElementById('mapEditorContainer').style.display='flex';
|
||
// sync mode
|
||
document.querySelectorAll('input[name="editMode"]').forEach(radio=>{
|
||
radio.addEventListener('change',()=>{editMode=radio.value;});
|
||
});
|
||
const md = uploadedMapData || {
|
||
width:3,height:3,
|
||
walls:[],beepers:[],
|
||
karelX:0,karelY:0,
|
||
karelDir:'east',karelBag:0
|
||
};
|
||
document.getElementById('editWidth').value=md.width;
|
||
document.getElementById('editHeight').value=md.height;
|
||
document.getElementById('karelDirSelect').value=md.karelDir;
|
||
// beeper bag input
|
||
const bagInput=document.getElementById('beeperBagInput');
|
||
bagInput.value=md.karelBag;
|
||
bagInput.onchange=()=>{
|
||
let v=parseInt(bagInput.value,10);
|
||
if(isNaN(v)||v<0)v=0;
|
||
if(v>99)v=99;
|
||
bagInput.value=v;
|
||
editorMapData.karelBag=v;
|
||
};
|
||
editorMapData=JSON.parse(JSON.stringify(md));
|
||
renderEditorMap();
|
||
}
|
||
|
||
function renderEditorMap(){
|
||
const w=editorMapData.width,h=editorMapData.height,total=w*4+1;
|
||
let out='';
|
||
// top border
|
||
for(let i=0;i<total;i++){
|
||
const ch=(i===0||i===total-1)?'+':'-';
|
||
out+=`<span class="map-border editor-border">${ch}</span>`;
|
||
}
|
||
out+='<br>';
|
||
// rows & toggles
|
||
for(let y=h-1;y>=0;y--){
|
||
let row='';
|
||
for(let x=0;x<w;x++){
|
||
if(x===0){
|
||
row+='<span class="map-border">|</span>';
|
||
} else {
|
||
const hasV=editorMapData.walls.some(wl=>wl.x===x-1&&wl.y===y&&wl.direction==='east');
|
||
row+=`<span class="clickable-wall ${hasV?'wall-border':'map-border'}"`
|
||
+` data-x="${x-1}" data-y="${y}" data-dir="east">`
|
||
+`${hasV?'|':' '}</span>`;
|
||
}
|
||
let c='.';
|
||
const bp=editorMapData.beepers.find(b=>b.x===x&&b.y===y);
|
||
if(bp)c=bp.count.toString();
|
||
if(editorMapData.karelX===x&&editorMapData.karelY===y){
|
||
c={'north':'↑','east':'→','south':'↓','west':'←'}[editorMapData.karelDir];
|
||
}
|
||
row+=`<span class="cell-content" data-x="${x}" data-y="${y}"> ${c} </span>`;
|
||
}
|
||
row+='<span class="map-border">|</span><br>';
|
||
out+=row;
|
||
if(y>0){
|
||
let div='';
|
||
for(let x=0;x<w;x++){
|
||
if(x===0)div+='<span class="map-border">+</span>';
|
||
else div+=' ';
|
||
const hasH=editorMapData.walls.some(wl=>wl.x===x&&wl.y===y&&wl.direction==='south');
|
||
div+=`<span class="clickable-wall ${hasH?'wall-border':'map-border'}"`
|
||
+` data-x="${x}" data-y="${y}" data-dir="south">`
|
||
+`${hasH?'---':' '}</span>`;
|
||
}
|
||
div+='<span class="map-border">+</span><br>';
|
||
out+=div;
|
||
}
|
||
}
|
||
// bottom border
|
||
for(let i=0;i<total;i++){
|
||
const ch=(i===0||i===total-1)?'+':'-';
|
||
out+=`<span class="map-border editor-border">${ch}</span>`;
|
||
}
|
||
out+='<br>';
|
||
document.getElementById('editorMap').innerHTML=out;
|
||
// bind wall toggles
|
||
document.querySelectorAll('#editorMap .clickable-wall').forEach(el=>{
|
||
el.onclick=()=>{
|
||
if(editMode!=='wall')return;
|
||
const x=+el.dataset.x,y=+el.dataset.y,d=el.dataset.dir;
|
||
const idx=editorMapData.walls.findIndex(wl=>wl.x===x&&wl.y===y&&wl.direction===d);
|
||
if(idx>=0)editorMapData.walls.splice(idx,1);
|
||
else editorMapData.walls.push({x,y,direction:d});
|
||
renderEditorMap();
|
||
};
|
||
});
|
||
// bind cell clicks
|
||
document.querySelectorAll('#editorMap .cell-content').forEach(el=>{
|
||
el.onclick=ev=>{
|
||
const x=+el.dataset.x,y=+el.dataset.y;
|
||
if(editMode==='wall'){
|
||
const d=document.getElementById('karelDirSelect').value;
|
||
const idx=editorMapData.walls.findIndex(wl=>wl.x===x&&wl.y===y&&wl.direction===d);
|
||
if(idx>=0)editorMapData.walls.splice(idx,1);
|
||
else editorMapData.walls.push({x,y,direction:d});
|
||
} else if(editMode==='beeper'){
|
||
let b=editorMapData.beepers.find(b=>b.x===x&&b.y===y);
|
||
if(ev.ctrlKey){
|
||
if(b){b.count--; if(b.count<=0)editorMapData.beepers=editorMapData.beepers.filter(z=>z!==b);}
|
||
} else {
|
||
if(!b)editorMapData.beepers.push({x,y,count:1});
|
||
else b.count++;
|
||
}
|
||
} else if(editMode==='karel'){
|
||
editorMapData.karelX=x;
|
||
editorMapData.karelY=y;
|
||
editorMapData.karelDir=document.getElementById('karelDirSelect').value;
|
||
}
|
||
renderEditorMap();
|
||
};
|
||
});
|
||
}
|
||
|
||
// Editor buttons
|
||
document.getElementById('editMapButton').onclick=showEditor;
|
||
document.getElementById('discardEditorMapButton').onclick=showMain;
|
||
document.getElementById('generateMapButton').onclick=()=>{
|
||
let w=parseInt(document.getElementById('editWidth').value,10),
|
||
h=parseInt(document.getElementById('editHeight').value,10);
|
||
if(isNaN(w)||w<3)w=3;
|
||
if(isNaN(h)||h<3)h=3;
|
||
document.getElementById('editWidth').value=w;
|
||
document.getElementById('editHeight').value=h;
|
||
editorMapData={
|
||
width:w,height:h,
|
||
walls:[],beepers:[],
|
||
karelX:0,karelY:0,
|
||
karelDir:document.getElementById('karelDirSelect').value,
|
||
karelBag:0
|
||
};
|
||
// reset bag input too
|
||
document.getElementById('beeperBagInput').value=0;
|
||
renderEditorMap();
|
||
};
|
||
|
||
// Import from editor
|
||
document.getElementById('importEditorMapButton').onclick=()=>{
|
||
uploadedMapData=JSON.parse(JSON.stringify(editorMapData));
|
||
const md=uploadedMapData, world=new World(md.width,md.height);
|
||
md.walls.forEach(w=>{
|
||
world.walls[w.x][w.y][w.direction]=true;
|
||
if(w.direction==='east'&&w.x+1<world.width)world.walls[w.x+1][w.y].west=true;
|
||
if(w.direction==='south'&&w.y-1>=0)world.walls[w.x][w.y-1].north=true;
|
||
});
|
||
md.beepers.forEach(b=>{for(let i=0;i<b.count;i++)world.addBeeper(b.x,b.y);});
|
||
const dirNum={north:1,east:2,south:3,west:4}[md.karelDir]-1;
|
||
const karel=new Karel(world,md.karelX,md.karelY,dirNum);
|
||
karel.beeperBag=md.karelBag;
|
||
currentWorld=world;currentKarel=karel;context.karel=karel;
|
||
renderWorld(world,karel);
|
||
showMain();
|
||
};
|
||
|
||
// Upload map file
|
||
document.getElementById('uploadMapButton').onclick=()=>{
|
||
const inp=document.getElementById('mapFileInput');
|
||
if(!inp.files.length){alert('Select a file.');return;}
|
||
const file=inp.files[0],ext=file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||
if(!['.txt','.kw'].includes(ext)){alert('Only .txt/.kw allowed.');return;}
|
||
const reader=new FileReader();
|
||
reader.onload=e=>{
|
||
try{
|
||
const md=parseMapFile(e.target.result);
|
||
uploadedMapData=md;
|
||
const world=new World(md.width,md.height);
|
||
md.walls.forEach(w=>{
|
||
world.walls[w.x][w.y][w.direction]=true;
|
||
if(w.direction==='east'&&w.x+1<world.width)world.walls[w.x+1][w.y].west=true;
|
||
if(w.direction==='south'&&w.y-1>=0)world.walls[w.x][w.y-1].north=true;
|
||
});
|
||
md.beepers.forEach(b=>{for(let i=0;i<b.count;i++)world.addBeeper(b.x,b.y);});
|
||
const dirNum={north:1,east:2,south:3,west:4}[md.karelDir]-1;
|
||
const karel=new Karel(world,md.karelX,md.karelY,dirNum);
|
||
karel.beeperBag=md.karelBag;
|
||
currentWorld=world;currentKarel=karel;context.karel=karel;
|
||
renderWorld(world,karel);
|
||
log('Map uploaded.');
|
||
}catch(err){
|
||
alert('Error parsing map: '+err.message);
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
};
|
||
|
||
// Stop button
|
||
document.getElementById('stopButton').onclick=()=>{
|
||
stopRequested=true;
|
||
log('⏹ Execution stopped by user.');
|
||
};
|
||
|
||
/**********************************************************************
|
||
* 8. COMPILE & RUN
|
||
**********************************************************************/
|
||
document.getElementById('runButton').onclick=async()=>{
|
||
stopRequested=false;
|
||
document.getElementById('console').textContent='';
|
||
|
||
// build world & karel
|
||
let world,karel;
|
||
if(uploadedMapData){
|
||
const md=uploadedMapData;
|
||
world=new World(md.width,md.height);
|
||
md.walls.forEach(w=>{
|
||
world.walls[w.x][w.y][w.direction]=true;
|
||
if(w.direction==='east'&&w.x+1<world.width)world.walls[w.x+1][w.y].west=true;
|
||
if(w.direction==='south'&&w.y-1>=0)world.walls[w.x][w.y-1].north=true;
|
||
});
|
||
md.beepers.forEach(b=>{for(let i=0;i<b.count;i++)world.addBeeper(b.x,b.y);});
|
||
const dirNum={north:1,east:2,south:3,west:4}[md.karelDir]-1;
|
||
karel=new Karel(world,md.karelX,md.karelY,dirNum);
|
||
karel.beeperBag=md.karelBag;
|
||
} else {
|
||
world=new World(10,10);
|
||
karel=new Karel(world,0,0,DIRECTIONS.EAST);
|
||
}
|
||
currentWorld=world;currentKarel=karel;context.karel=karel;
|
||
renderWorld(world,karel);
|
||
|
||
// compile
|
||
log('=== Compilation ===');
|
||
let tokens,prog;
|
||
try{
|
||
tokens=new Lexer(document.getElementById('editor').value).tokenize();
|
||
prog=new Parser(tokens).parseProgram();
|
||
log('✅ Compilation successful.');
|
||
}catch(e){
|
||
return log(`⛔ Compilation error: ${e.message}`);
|
||
}
|
||
|
||
// execute
|
||
log('=== Execution ===');
|
||
try{
|
||
await executeProgramAsync(prog,context);
|
||
if(!stopRequested) log('✅ Execution complete.');
|
||
}catch(e){
|
||
if(e.message!=='Execution stopped by user'){
|
||
log(`⛔ Runtime error: ${e.message}`);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Map-file parser (unchanged) ...
|
||
function parseMapFile(txt){
|
||
const lines=txt.split(/\r?\n/).map(l=>l.trim()).filter(l=>l);
|
||
if(!lines.length)throw new Error('File is empty.');
|
||
const hdr=lines[0].split(/\s+/);
|
||
if(hdr.length!==6)throw new Error('Header must have 6 tokens.');
|
||
const w=parseInt(hdr[0],10),h=parseInt(hdr[1],10),
|
||
x=parseInt(hdr[2],10)-1,y=parseInt(hdr[3],10)-1,
|
||
dir=hdr[4].toUpperCase(),bag=parseInt(hdr[5],10);
|
||
if([w,h,x+1,y+1,bag].some(n=>isNaN(n)))throw new Error('Invalid header.');
|
||
if(!['N','E','S','W'].includes(dir))throw new Error('Dir must be N/E/S/W.');
|
||
const mapDir={'N':'north','E':'east','S':'south','W':'west'};
|
||
const md={width:w,height:h,karelX:x,karelY:y,
|
||
karelDir:mapDir[dir],karelBag:bag,
|
||
walls:[],beepers:[]};
|
||
lines.slice(1).forEach((l,i)=>{
|
||
const p=l.split(/\s+/),t=p[0].toUpperCase();
|
||
if(t==='W'){
|
||
if(p.length!==4)throw new Error(`Line ${i+2}: W needs 4 tokens.`);
|
||
let xx=parseInt(p[1],10)-1,yy=parseInt(p[2],10)-1,d=p[3].toUpperCase();
|
||
if(isNaN(xx)||isNaN(yy)||!['N','E','S','W'].includes(d))
|
||
throw new Error(`Line ${i+2}: invalid wall.`);
|
||
if(d==='N'&&yy+1<h) md.walls.push({x:xx,y:yy+1,direction:'south'});
|
||
else if(d==='S') md.walls.push({x:xx,y:yy,direction:'south'});
|
||
else if(d==='E') md.walls.push({x:xx,y:yy,direction:'east'});
|
||
else if(d==='W'&&xx-1>=0)md.walls.push({x:xx-1,y:yy,direction:'east'});
|
||
}
|
||
else if(t==='B'){
|
||
if(p.length!==4)throw new Error(`Line ${i+2}: B needs 4 tokens.`);
|
||
let xx=parseInt(p[1],10)-1,yy=parseInt(p[2],10)-1,
|
||
c =parseInt(p[3],10);
|
||
if(isNaN(xx)||isNaN(yy)||isNaN(c))
|
||
throw new Error(`Line ${i+2}: invalid beeper.`);
|
||
md.beepers.push({x:xx,y:yy,count:c});
|
||
}
|
||
else throw new Error(`Line ${i+2}: must start W or B.`);
|
||
});
|
||
return md;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|