← back to 90210 Agentabrams

games/rodeo-runner/RodeoDriveRunner.aa

385 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Rodeo Drive Runner — 90210 Endless Cruise</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@500;600;700&display=swap');
*{margin:0;padding:0;box-sizing:border-box;}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%;height:100%;}
html,body{height:100%;overflow:hidden;}
body{
  touch-action:manipulation;-webkit-tap-highlight-color:transparent;
  -webkit-user-select:none;user-select:none;-webkit-touch-callout:none;
  font-family:"Inter",-apple-system,"Segoe UI",Helvetica,Arial,sans-serif;
  background:#0d0907; color:#efe3c8;
  display:flex; align-items:center; justify-content:center;
}
:root{
  --ivory:#efe3c8; --ivory-dk:#c9b07a; --gold:#e9c46a; --gold-hi:#f6de9b;
  --peach:#f4a261; --burnt:#e76f51; --ink:#1a1410;
  --font-disp:"Playfair Display",Georgia,serif;
}
#wrap{ position:relative; width:min(560px,100vw); height:100%; max-height:100vh; }
canvas{ display:block; width:100%; height:100%; touch-action:none; }

/* Overlays */
.overlay{
  position:absolute; inset:0; display:flex; flex-direction:column;
  align-items:center; justify-content:center; text-align:center;
  padding:24px; gap:6px;
  background:radial-gradient(circle at 50% 30%, rgba(26,20,16,.55), rgba(8,6,4,.82) 70%);
  backdrop-filter:blur(2px); z-index:5;
}
.overlay.hidden{ display:none; }
.kicker{ font-size:12px; letter-spacing:.34em; text-transform:uppercase; color:var(--ivory-dk); }
.title{
  font-family:var(--font-disp); font-weight:900; line-height:.98;
  font-size:clamp(30px,9vw,52px); margin:4px 0;
  background:linear-gradient(180deg,var(--gold-hi),var(--peach) 70%,var(--burnt));
  -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
  filter:drop-shadow(0 4px 10px rgba(0,0,0,.4));
}
.palms{ font-size:24px; letter-spacing:6px; margin:2px 0 8px; }
.blurb{ font-size:13px; color:var(--ivory-dk); max-width:34ch; line-height:1.6; margin-bottom:16px; }
.big-score{ font-family:var(--font-disp); font-weight:900; font-size:clamp(40px,13vw,68px); color:var(--gold); line-height:1; }
.sub{ font-size:13px; color:var(--ivory-dk); }
.gold{ color:var(--gold); font-weight:700; }
.btn{
  font-weight:700; font-size:14px; letter-spacing:.06em; text-transform:uppercase;
  color:var(--ink); cursor:pointer; border:none; margin-top:16px;
  padding:14px 30px; border-radius:999px;
  background:linear-gradient(180deg,var(--gold-hi),var(--peach));
  box-shadow:0 12px 28px rgba(0,0,0,.45);
  transition:transform .14s, filter .14s;
}
.btn:hover{ transform:translateY(-2px); filter:brightness(1.06); }
.btn:active{ transform:translateY(0); }
.btn.ghost{ background:transparent; color:var(--ivory); border:1px solid rgba(233,196,106,.5); box-shadow:none; margin-top:10px; }
.btn.ghost:hover{ border-color:var(--gold); filter:none; }
.controls-hint{ font-size:11px; color:var(--ivory-dk); margin-top:14px; letter-spacing:.08em; opacity:.85; }
.new-best{ display:inline-block; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
  color:var(--ink); background:linear-gradient(180deg,var(--gold-hi),var(--peach));
  padding:4px 12px; border-radius:999px; margin-bottom:10px; }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="c"></canvas>

  <div class="overlay" id="ovStart">
    <div class="kicker">Beverly Hills · 90210</div>
    <div class="title">Rodeo Drive<br>Runner</div>
    <div class="palms">🌴&nbsp;🏎️&nbsp;🌴</div>
    <div class="blurb">Cruise Rodeo Drive at full throttle. Swerve the traffic, snag 🥂 &amp; 💎 to stack a combo, and see how far you get before you kiss a bumper.</div>
    <button class="btn" id="startBtn">Start Cruising</button>
    <div class="controls-hint">← → or A / D · tap left / right · swipe</div>
  </div>

  <div class="overlay hidden" id="ovOver">
    <div class="kicker">Crash on Rodeo</div>
    <div id="bestBadge"></div>
    <div class="big-score" id="finalScore">0</div>
    <div class="sub"><span id="finalDist">0</span> m cruised · best combo <span class="gold" id="finalCombo">0×</span></div>
    <div class="sub" id="bestLine" style="margin-top:8px"></div>
    <button class="btn" id="againBtn">Cruise Again</button>
    <button class="btn ghost" id="shareBtn">Share Score</button>
    <div class="controls-hint" id="shareLine"></div>
  </div>
</div>

<script>
(function(){
  "use strict";
  var LS_BEST = "rodeoRunner.best";

  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  var wrap = document.getElementById("wrap");
  var ovStart = document.getElementById("ovStart");
  var ovOver = document.getElementById("ovOver");

  // ---- dimensions ----
  var W=0, H=0, dpr=1;
  var road = { x:0, w:0 };        // road left + width
  var LANES = 3;
  function laneCenter(i){ return road.x + road.w*(i+0.5)/LANES; }

  function resize(){
    dpr = Math.min(window.devicePixelRatio||1, 2);
    W = wrap.clientWidth; H = wrap.clientHeight;
    canvas.width = Math.round(W*dpr); canvas.height = Math.round(H*dpr);
    ctx.setTransform(dpr,0,0,dpr,0,0);
    var roadW = Math.min(W*0.74, 420);
    road.w = roadW; road.x = (W-roadW)/2;
    carW = Math.min(road.w/LANES*0.62, 66);
    carH = carW*1.7;
    playerY = H - carH*1.35;
  }

  // ---- entities ----
  var carW, carH, playerY;
  var state = "start"; // start | play | over
  var playerLane = 1, playerX = 0, targetX = 0;
  var obstacles = [], pickups = [], dashes = [], palms = [];
  var scrollY = 0, speed = 0, dist = 0, score = 0, combo = 0, bestCombo = 0;
  var spawnT = 0, pickupT = 0, elapsed = 0, godMode = false;
  var best = parseInt(localStorage.getItem(LS_BEST)||"0",10) || 0;

  var CAR_COLORS = ["#c0392b","#2c3e50","#16a085","#8e44ad","#d35400","#2980b9","#7f8c8d","#c39bd3"];

  function reset(){
    obstacles=[]; pickups=[]; dashes=[]; palms=[];
    playerLane=1; playerX=targetX=laneCenter(1);
    scrollY=0; speed=260; dist=0; score=0; combo=0; bestCombo=0;
    spawnT=0.7; pickupT=1.4; elapsed=0;
    // pre-seed lane dashes and side palms
    for(var y=-40;y<H+40;y+=64) dashes.push(y);
    for(var yp=-80;yp<H+80;yp+=150){ palms.push({y:yp,side:0}); palms.push({y:yp+75,side:1}); }
  }

  function start(){
    resize(); reset();
    state="play";
    ovStart.classList.add("hidden");
    ovOver.classList.add("hidden");
    last = performance.now();
  }

  // ---- input ----
  function move(dir){
    if(state!=="play") return;
    playerLane = Math.max(0, Math.min(LANES-1, playerLane + dir));
    targetX = laneCenter(playerLane);
  }
  document.addEventListener("keydown", function(e){
    var k=e.key.toLowerCase();
    if(k==="arrowleft"||k==="a"){ e.preventDefault(); move(-1); }
    else if(k==="arrowright"||k==="d"){ e.preventDefault(); move(1); }
    else if((k==="enter"||k===" ")){ if(state!=="play"){ e.preventDefault(); start(); } }
  });
  // pointer: tap halves + swipe
  var downX=null;
  canvas.addEventListener("pointerdown", function(e){ downX=e.clientX; });
  canvas.addEventListener("pointerup", function(e){
    if(state!=="play"){ return; }
    if(downX==null) return;
    var dx=e.clientX-downX;
    if(Math.abs(dx)>24){ move(dx>0?1:-1); }
    else { move(e.clientX < wrap.getBoundingClientRect().left + W/2 ? -1 : 1); }
    downX=null;
  });

  // ---- spawning (always leaves at least one lane open) ----
  function spawnObstacles(){
    var busy = {};
    var n = Math.random()<0.32 ? 2 : 1;   // never 3 -> a gap always exists
    for(var i=0;i<n;i++){
      var lane;
      do { lane = (Math.random()*LANES)|0; } while(busy[lane]);
      busy[lane]=1;
      obstacles.push({ lane:lane, y:-carH, color:CAR_COLORS[(Math.random()*CAR_COLORS.length)|0] });
    }
  }
  function spawnPickup(){
    var lane=(Math.random()*LANES)|0;
    var kind = Math.random()<0.35 ? "💎" : "🥂";
    var val = kind==="💎" ? 250 : 100;
    pickups.push({ lane:lane, y:-30, kind:kind, val:val });
  }

  // ---- loop ----
  var last=performance.now();
  function frame(now){
    requestAnimationFrame(frame);
    var dt = Math.min((now-last)/1000, 0.05); last=now;
    if(state==="play") update(dt);
    render();
  }

  function update(dt){
    elapsed += dt;
    speed = 260 + Math.min(elapsed*10, 320);   // ramps to ~580 px/s
    var dy = speed*dt;
    dist += dy/40;
    score += dy*0.12;

    // scroll decorations
    for(var i=0;i<dashes.length;i++){ dashes[i]+=dy; if(dashes[i]>H+40) dashes[i]-=(H+80); }
    for(var p=0;p<palms.length;p++){ palms[p].y+=dy*0.9; if(palms[p].y>H+80) palms[p].y-=(H+160); }

    // obstacles
    spawnT-=dt;
    if(spawnT<=0){ spawnObstacles(); spawnT = Math.max(0.42, 0.95 - elapsed*0.012); }
    for(var o=obstacles.length-1;o>=0;o--){
      obstacles[o].y += dy;
      if(obstacles[o].y > H+carH){ obstacles.splice(o,1); continue; }
      if(!godMode && hit(obstacles[o])){ return crash(); }
    }

    // pickups
    pickupT-=dt;
    if(pickupT<=0){ spawnPickup(); pickupT = 0.9 + Math.random()*0.9; }
    for(var k=pickups.length-1;k>=0;k--){
      pickups[k].y += dy;
      if(pickups[k].y > H+30){ pickups.splice(k,1); continue; }
      if(collect(pickups[k])){
        combo++; if(combo>bestCombo) bestCombo=combo;
        score += pickups[k].val * (1 + (combo-1)*0.2);
        pickups.splice(k,1);
      }
    }

    // ease player toward target lane
    playerX += (targetX-playerX) * Math.min(1, dt*16);
  }

  function hit(ob){
    if(ob.lane!==playerLane) return false;
    var oy=ob.y, py=playerY;
    return (oy+carH*0.9 > py) && (oy < py+carH*0.9);
  }
  function collect(pk){
    if(pk.lane!==playerLane) return false;
    return (pk.y+22 > playerY) && (pk.y < playerY+carH);
  }

  function crash(){
    state="over";
    var finalScore = Math.round(score);
    document.getElementById("finalScore").textContent = finalScore.toLocaleString();
    document.getElementById("finalDist").textContent = Math.round(dist).toLocaleString();
    document.getElementById("finalCombo").textContent = bestCombo + "×";
    var bb=document.getElementById("bestBadge"), bl=document.getElementById("bestLine");
    if(finalScore > best){ best=finalScore; localStorage.setItem(LS_BEST,String(best));
      bb.innerHTML='<span class="new-best">✨ New Personal Best</span>'; bl.textContent="";
    } else { bb.innerHTML=""; bl.innerHTML='Best: <span class="gold">'+best.toLocaleString()+'</span>'; }
    document.getElementById("shareLine").textContent="";
    ovOver.classList.remove("hidden");
  }

  // ---- render ----
  function render(){
    // sky / asphalt
    var g=ctx.createLinearGradient(0,0,0,H);
    g.addColorStop(0,"#241a12"); g.addColorStop(0.5,"#17110c"); g.addColorStop(1,"#0d0907");
    ctx.fillStyle=g; ctx.fillRect(0,0,W,H);

    // road
    ctx.fillStyle="#20232a";
    roundRect(road.x, -10, road.w, H+20, 0); ctx.fill();
    // road edge glow (gold curbs)
    ctx.fillStyle="rgba(233,196,106,.5)";
    ctx.fillRect(road.x-3,0,3,H); ctx.fillRect(road.x+road.w,0,3,H);

    // lane dashes (gold)
    ctx.fillStyle="rgba(246,222,155,.55)";
    for(var l=1;l<LANES;l++){
      var lx=road.x+road.w*l/LANES;
      for(var i=0;i<dashes.length;i++){ ctx.fillRect(lx-2, dashes[i], 4, 30); }
    }

    // side palms
    ctx.font="26px system-ui,Apple Color Emoji,Segoe UI Emoji";
    ctx.textAlign="center"; ctx.textBaseline="middle";
    for(var p=0;p<palms.length;p++){
      var px = palms[p].side===0 ? road.x-22 : road.x+road.w+22;
      if(px>6 && px<W-6) ctx.fillText("🌴", px, palms[p].y);
    }

    // pickups
    for(var k=0;k<pickups.length;k++){
      ctx.font="24px system-ui,Apple Color Emoji,Segoe UI Emoji";
      ctx.fillText(pickups[k].kind, laneCenter(pickups[k].lane), pickups[k].y);
    }

    // obstacle cars
    for(var o=0;o<obstacles.length;o++){
      drawCar(laneCenter(obstacles[o].lane), obstacles[o].y, obstacles[o].color, false);
    }

    // player convertible
    if(state!=="start") drawCar(playerX, playerY, "#e9c46a", true);

    // HUD
    if(state==="play"){
      ctx.textAlign="left"; ctx.textBaseline="alphabetic";
      ctx.font="700 20px 'Playfair Display',Georgia,serif";
      ctx.fillStyle="#efe3c8";
      ctx.fillText(Math.round(score).toLocaleString(), 16, 32);
      ctx.font="600 11px Inter,sans-serif"; ctx.fillStyle="#c9b07a";
      ctx.fillText("SCORE", 16, 46);
      if(combo>1){
        ctx.textAlign="right";
        ctx.font="700 18px 'Playfair Display',serif"; ctx.fillStyle="#f4a261";
        ctx.fillText("🔥 "+combo+"×", W-16, 32);
      }
    }
  }

  function drawCar(cx, cy, color, isPlayer){
    var w=carW, h=carH, x=cx-w/2, y=cy;
    ctx.save();
    // shadow
    ctx.fillStyle="rgba(0,0,0,.35)"; roundRect(x+3, y+6, w, h, 10); ctx.fill();
    // body
    ctx.fillStyle=color; roundRect(x, y, w, h, 10); ctx.fill();
    // top highlight
    var gg=ctx.createLinearGradient(x,y,x,y+h);
    gg.addColorStop(0,"rgba(255,255,255,.25)"); gg.addColorStop(0.5,"rgba(255,255,255,0)");
    ctx.fillStyle=gg; roundRect(x,y,w,h,10); ctx.fill();
    // windshield
    ctx.fillStyle="rgba(20,26,34,.85)";
    roundRect(x+w*0.16, y+h*0.16, w*0.68, h*0.26, 5); ctx.fill();
    // rear window
    roundRect(x+w*0.16, y+h*0.6, w*0.68, h*0.22, 5); ctx.fill();
    if(isPlayer){ // gold trim
      ctx.strokeStyle="rgba(255,255,255,.6)"; ctx.lineWidth=1.5;
      roundRect(x,y,w,h,10); ctx.stroke();
    }
    ctx.restore();
  }

  function roundRect(x,y,w,h,r){
    ctx.beginPath();
    ctx.moveTo(x+r,y);
    ctx.arcTo(x+w,y,x+w,y+h,r);
    ctx.arcTo(x+w,y+h,x,y+h,r);
    ctx.arcTo(x,y+h,x,y,r);
    ctx.arcTo(x,y,x+w,y,r);
    ctx.closePath();
  }

  // ---- share ----
  function share(){
    var s=Math.round(score);
    var text="I cruised Rodeo Drive for "+s.toLocaleString()+" points in Rodeo Drive Runner. 🏎️💎 Beat me on 90210 row!";
    var url="https://90210.agentabrams.com/#rodeo-runner";
    if(navigator.share){ navigator.share({title:"Rodeo Drive Runner",text:text,url:url}).catch(function(){}); }
    else if(navigator.clipboard){ navigator.clipboard.writeText(text+" "+url).then(function(){ document.getElementById("shareLine").textContent="📋 Score + link copied."; }).catch(function(){}); }
  }

  // ---- wire ----
  document.getElementById("startBtn").addEventListener("click", start);
  document.getElementById("againBtn").addEventListener("click", start);
  document.getElementById("shareBtn").addEventListener("click", share);
  window.addEventListener("resize", function(){ resize(); if(state==="play"){ targetX=laneCenter(playerLane);} });

  resize();
  requestAnimationFrame(frame);

  // Automation/test hook (mirrors other hub games).
  window.__runner = {
    start:start,
    left:function(){ move(-1); },
    right:function(){ move(1); },
    crash:function(){ if(state==="play") crash(); },
    god:function(on){ godMode=!!on; },
    state:function(){ return { state:state, score:Math.round(score), dist:Math.round(dist), combo:combo, bestCombo:bestCombo, lane:playerLane, best:best }; }
  };
})();
</script>
</body>
</html>