diff --git a/android/src/main/assets/custom_tiles/prison_exit_new.png b/android/src/main/assets/custom_tiles/prison_exit_new.png index f7251f44b..caa5f3c5d 100644 Binary files a/android/src/main/assets/custom_tiles/prison_exit_new.png and b/android/src/main/assets/custom_tiles/prison_exit_new.png differ diff --git a/android/src/main/assets/items.png b/android/src/main/assets/items.png index baf9248a0..1b517df9e 100644 Binary files a/android/src/main/assets/items.png and b/android/src/main/assets/items.png differ diff --git a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/actors/mobs/NewTengu.java b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/actors/mobs/NewTengu.java index d5b4da00c..ea44b026b 100644 --- a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/actors/mobs/NewTengu.java +++ b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/actors/mobs/NewTengu.java @@ -26,25 +26,49 @@ import com.shatteredpixel.shatteredpixeldungeon.Badges; import com.shatteredpixel.shatteredpixeldungeon.Dungeon; import com.shatteredpixel.shatteredpixeldungeon.actors.Actor; import com.shatteredpixel.shatteredpixeldungeon.actors.Char; +import com.shatteredpixel.shatteredpixeldungeon.actors.blobs.Blob; +import com.shatteredpixel.shatteredpixeldungeon.actors.blobs.Electricity; +import com.shatteredpixel.shatteredpixeldungeon.actors.blobs.Fire; +import com.shatteredpixel.shatteredpixeldungeon.actors.buffs.Buff; import com.shatteredpixel.shatteredpixeldungeon.actors.buffs.LockedFloor; +import com.shatteredpixel.shatteredpixeldungeon.actors.hero.Hero; import com.shatteredpixel.shatteredpixeldungeon.actors.hero.HeroSubClass; +import com.shatteredpixel.shatteredpixeldungeon.effects.BlobEmitter; import com.shatteredpixel.shatteredpixeldungeon.effects.CellEmitter; +import com.shatteredpixel.shatteredpixeldungeon.effects.FloatingText; +import com.shatteredpixel.shatteredpixeldungeon.effects.Lightning; import com.shatteredpixel.shatteredpixeldungeon.effects.Speck; +import com.shatteredpixel.shatteredpixeldungeon.effects.particles.BlastParticle; +import com.shatteredpixel.shatteredpixeldungeon.effects.particles.FlameParticle; +import com.shatteredpixel.shatteredpixeldungeon.effects.particles.SmokeParticle; +import com.shatteredpixel.shatteredpixeldungeon.effects.particles.SparkParticle; +import com.shatteredpixel.shatteredpixeldungeon.items.Heap; +import com.shatteredpixel.shatteredpixeldungeon.items.Item; import com.shatteredpixel.shatteredpixeldungeon.items.TomeOfMastery; import com.shatteredpixel.shatteredpixeldungeon.items.artifacts.DriedRose; import com.shatteredpixel.shatteredpixeldungeon.items.artifacts.LloydsBeacon; +import com.shatteredpixel.shatteredpixeldungeon.items.bombs.Bomb; import com.shatteredpixel.shatteredpixeldungeon.levels.Level; import com.shatteredpixel.shatteredpixeldungeon.levels.NewPrisonBossLevel; import com.shatteredpixel.shatteredpixeldungeon.mechanics.Ballistica; import com.shatteredpixel.shatteredpixeldungeon.messages.Messages; import com.shatteredpixel.shatteredpixeldungeon.scenes.GameScene; +import com.shatteredpixel.shatteredpixeldungeon.sprites.CharSprite; +import com.shatteredpixel.shatteredpixeldungeon.sprites.ItemSprite; +import com.shatteredpixel.shatteredpixeldungeon.sprites.ItemSpriteSheet; import com.shatteredpixel.shatteredpixeldungeon.sprites.TenguSprite; +import com.shatteredpixel.shatteredpixeldungeon.tiles.DungeonTilemap; import com.shatteredpixel.shatteredpixeldungeon.ui.BossHealthBar; +import com.shatteredpixel.shatteredpixeldungeon.utils.BArray; import com.shatteredpixel.shatteredpixeldungeon.utils.GLog; import com.watabou.noosa.audio.Sample; import com.watabou.utils.Bundle; +import com.watabou.utils.PathFinder; +import com.watabou.utils.PointF; import com.watabou.utils.Random; +import java.util.HashSet; + //TODO currently has attack/defence stats for testing, need to add those public class NewTengu extends Mob { @@ -88,12 +112,7 @@ public class NewTengu extends Mob { public void damage(int dmg, Object src) { NewPrisonBossLevel.State state = ((NewPrisonBossLevel)Dungeon.level).state(); - int hpBracket; - if (state == NewPrisonBossLevel.State.FIGHT_START){ - hpBracket = 20; - } else { - hpBracket = 20; - } + int hpBracket = 20; int beforeHitHP = HP; super.damage(dmg, src); @@ -131,7 +150,7 @@ public class NewTengu extends Mob { //phase 1 of the fight is over if (state == NewPrisonBossLevel.State.FIGHT_START && HP <= HT/2){ - HP = (HT/2)-1; + HP = (HT/2); yell(Messages.get(this, "interesting")); ((NewPrisonBossLevel)Dungeon.level).progress(); BossHealthBar.bleed(true); @@ -202,7 +221,7 @@ public class NewTengu extends Mob { do { newPos = ((NewPrisonBossLevel)Dungeon.level).randomTenguCellPos(); - } while ( (level.distance(newPos, enemy.pos) <= 2 || Actor.findChar(newPos) != null)); + } while ( (level.distance(newPos, enemy.pos) < 3 || Actor.findChar(newPos) != null)); if (level.heroFOV[pos]) CellEmitter.get( pos ).burst( Speck.factory( Speck.WOOL ), 6 ); @@ -217,15 +236,15 @@ public class NewTengu extends Mob { float fill = 0.9f - 0.5f*((HP-80)/80f); level.placeTrapsInTenguCell(fill); - return; - - //otherwise.. TODO! + //otherwise, jump in a larger possible area, as the room is bigger } else { do { newPos = Random.Int(level.length()); } while ( level.solid[newPos] || - level.distance(newPos, enemy.pos) < 6 || + level.distance(newPos, enemy.pos) < 5 || + level.distance(newPos, enemy.pos) > 7 || + level.distance(newPos, pos) < 6 || Actor.findChar(newPos) != null); if (level.heroFOV[pos]) CellEmitter.get( pos ).burst( Speck.factory( Speck.WOOL ), 6 ); @@ -312,4 +331,453 @@ public class NewTengu extends Mob { } } } + + //***************************************************************************************** + //***** Tengu abilities. These are expressed in game logic as buffs, blobs, and items ***** + //***************************************************************************************** + + //****************** + //***Bomb Ability*** + //****************** + + public static class BombAbility extends Buff { + + public int bombPos; + private int timer = 3; + + @Override + public boolean act() { + + PointF p = DungeonTilemap.raisedTileCenterToWorld(bombPos); + if (timer == 3) { + FloatingText.show(p.x, p.y, bombPos, "3...", CharSprite.NEUTRAL); + PathFinder.buildDistanceMap( bombPos, BArray.not( Dungeon.level.solid, null ), 2 ); + for (int i = 0; i < PathFinder.distance.length; i++) { + if (PathFinder.distance[i] < Integer.MAX_VALUE) { + GameScene.add(Blob.seed(i, 4, BombBlob.class)); + } + } + } else if (timer == 2){ + FloatingText.show(p.x, p.y, bombPos, "2...", CharSprite.WARNING); + } else if (timer == 1){ + FloatingText.show(p.x, p.y, bombPos, "1...", CharSprite.NEGATIVE); + } else { + Heap h = Dungeon.level.heaps.get(bombPos); + if (h != null){ + for (Item i : h.items.toArray(new Item[0])){ + if (i instanceof BombItem){ + h.remove(i); + } + } + } + detach(); + return true; + } + + timer--; + spend(TICK); + return true; + } + + private static final String BOMB_POS = "bomb_pos"; + private static final String TIMER = "timer"; + + @Override + public void storeInBundle(Bundle bundle) { + super.storeInBundle(bundle); + bundle.put( BOMB_POS, bombPos ); + bundle.put( TIMER, timer ); + } + + @Override + public void restoreFromBundle(Bundle bundle) { + super.restoreFromBundle(bundle); + bombPos = bundle.getInt( BOMB_POS ); + timer = bundle.getInt( TIMER ); + } + + public static class BombBlob extends Blob { + { + actPriority = BUFF_PRIO - 1; + } + + @Override + protected void evolve() { + + boolean exploded = false; + + int cell; + for (int i = area.left; i < area.right; i++){ + for (int j = area.top; j < area.bottom; j++){ + cell = i + j* Dungeon.level.width(); + off[cell] = cur[cell] > 0 ? cur[cell] - 1 : 0; + + if (off[cell] > 0) { + volume += off[cell]; + } + + if (cur[cell] > 0 && off[cell] == 0){ + + Char ch = Actor.findChar(cell); + if (ch != null){ + int dmg = Random.NormalIntRange(5 + Dungeon.depth, 10 + Dungeon.depth*2); + dmg -= ch.drRoll(); + + if (dmg > 0) { + ch.damage(dmg, Bomb.class); + } + + if (ch == Dungeon.hero && !ch.isAlive()) { + Dungeon.fail(NewTengu.class); + } + } + + if (Dungeon.level.heroFOV[cell]) { + exploded = true; + CellEmitter.center(cell).burst(BlastParticle.FACTORY, 2); + } + } + } + } + + if (exploded){ + Sample.INSTANCE.play(Assets.SND_BLAST); + } + + } + + @Override + public void use(BlobEmitter emitter) { + super.use(emitter); + + emitter.pour( SmokeParticle.FACTORY, 0.1f ); + } + + @Override + public String tileDesc() { + return Messages.get(this, "desc"); + } + } + + public static class BombItem extends Item { + + { + dropsDownHeap = true; + + image = ItemSpriteSheet.TENGU_BOMB; + } + + @Override + public boolean doPickUp( Hero hero ) { + GLog.w( Messages.get(this, "cant_pickup") ); + return false; + } + + //TODO change for when tengu throws this + @Override + protected void onThrow(int cell) { + super.onThrow(cell); + Buff.append(curUser, BombAbility.class).bombPos = cell; + } + + @Override + public ItemSprite.Glowing glowing() { + return new ItemSprite.Glowing( 0xFF0000, 0.5f ); + } + } + } + + //****************** + //***Fire Ability*** + //****************** + + public static class FireAbility extends Buff { + + public int direction; + private int[] curCells; + + HashSet toCells = new HashSet<>(); + + @Override + public boolean act() { + + if (curCells == null){ + curCells = new int[1]; + curCells[0] = target.pos; + } + + toCells.clear(); + + for (Integer c : curCells){ + spreadFromCell( c ); + } + + for (Integer c : curCells){ + toCells.remove(c); + } + + if (toCells.isEmpty()){ + detach(); + } else { + curCells = new int[toCells.size()]; + int i = 0; + for (Integer c : toCells){ + GameScene.add(Blob.seed(c, 2, FireBlob.class)); + curCells[i] = c; + i++; + } + } + + spend(TICK); + return true; + } + + private void spreadFromCell( int cell ){ + if (!Dungeon.level.solid[cell + PathFinder.CIRCLE8[left(direction)]]){ + toCells.add(cell + PathFinder.CIRCLE8[left(direction)]); + } + if (!Dungeon.level.solid[cell + PathFinder.CIRCLE8[direction]]){ + toCells.add(cell + PathFinder.CIRCLE8[direction]); + } + if (!Dungeon.level.solid[cell + PathFinder.CIRCLE8[right(direction)]]){ + toCells.add(cell + PathFinder.CIRCLE8[right(direction)]); + } + } + + private int left(int direction){ + return direction == 0 ? 7 : direction-1; + } + + private int right(int direction){ + return direction == 7 ? 0 : direction+1; + } + + private static final String DIRECTION = "direction"; + private static final String CUR_CELLS = "cur_cells"; + + @Override + public void storeInBundle(Bundle bundle) { + super.storeInBundle(bundle); + bundle.put( DIRECTION, direction ); + bundle.put( CUR_CELLS, curCells ); + } + + @Override + public void restoreFromBundle(Bundle bundle) { + super.restoreFromBundle(bundle); + direction = bundle.getInt( DIRECTION ); + curCells = bundle.getIntArray( CUR_CELLS ); + } + + public static class FireBlob extends Blob { + + { + + actPriority = BUFF_PRIO - 1; + } + + @Override + protected void evolve() { + + boolean observe = false; + boolean burned = false; + + int cell; + for (int i = area.left; i < area.right; i++){ + for (int j = area.top; j < area.bottom; j++){ + cell = i + j* Dungeon.level.width(); + off[cell] = cur[cell] > 0 ? cur[cell] - 1 : 0; + + if (off[cell] > 0) { + volume += off[cell]; + } + + if (cur[cell] > 0 && off[cell] == 0){ + Fire.burn( cell ); + + if (Dungeon.level.flamable[cell]){ + Dungeon.level.destroy( cell ); + + observe = true; + GameScene.updateMap( cell ); + } + + if (Dungeon.level.heroFOV[cell]){ + burned = true; + CellEmitter.get(cell).start(FlameParticle.FACTORY, 0.03f, 10); + } + } + } + } + + if (observe) { + Dungeon.observe(); + } + + if (burned){ + Sample.INSTANCE.play(Assets.SND_BURNING); + } + } + + @Override + public void use(BlobEmitter emitter) { + super.use(emitter); + + emitter.pour( Speck.factory( Speck.STEAM ), 0.2f ); + } + + @Override + public String tileDesc() { + return Messages.get(this, "desc"); + } + } + } + + //********************* + //***Shocker Ability*** + //********************* + + public static class ShockerAbility extends Buff { + + public int shockerPos; + private Boolean shockingOrdinals = null; + + @Override + public boolean act() { + + if (shockingOrdinals == null){ + shockingOrdinals = Random.Int(2) == 1; + + spreadblob(); + } else if (shockingOrdinals){ + + target.sprite.parent.add(new Lightning(shockerPos - 1 - Dungeon.level.width(), shockerPos + 1 + Dungeon.level.width(), null)); + target.sprite.parent.add(new Lightning(shockerPos - 1 + Dungeon.level.width(), shockerPos + 1 - Dungeon.level.width(), null)); + + if (Dungeon.level.distance(Dungeon.hero.pos, shockerPos) <= 1){ + Sample.INSTANCE.play( Assets.SND_LIGHTNING ); + } + + shockingOrdinals = false; + spreadblob(); + } else { + + target.sprite.parent.add(new Lightning(shockerPos - Dungeon.level.width(), shockerPos + Dungeon.level.width(), null)); + target.sprite.parent.add(new Lightning(shockerPos - 1, shockerPos + 1, null)); + + if (Dungeon.level.distance(Dungeon.hero.pos, shockerPos) <= 1){ + Sample.INSTANCE.play( Assets.SND_LIGHTNING ); + } + + shockingOrdinals = true; + spreadblob(); + } + + spend(TICK); + return true; + } + + private void spreadblob(){ + GameScene.add(Blob.seed(shockerPos, 1, ShockerBlob.class)); + for (int i = shockingOrdinals ? 0 : 1; i < PathFinder.CIRCLE8.length; i += 2){ + if (!Dungeon.level.solid[shockerPos+PathFinder.CIRCLE8[i]]) { + GameScene.add(Blob.seed(shockerPos + PathFinder.CIRCLE8[i], 2, ShockerBlob.class)); + } + } + } + + private static final String SHOCKER_POS = "shocker_pos"; + private static final String SHOCKING_ORDINALS = "shocking_ordinals"; + + @Override + public void storeInBundle(Bundle bundle) { + super.storeInBundle(bundle); + bundle.put( SHOCKER_POS, shockerPos ); + bundle.put( SHOCKING_ORDINALS, shockingOrdinals ); + } + + @Override + public void restoreFromBundle(Bundle bundle) { + super.restoreFromBundle(bundle); + shockerPos = bundle.getInt( SHOCKER_POS ); + shockingOrdinals = bundle.getBoolean( SHOCKING_ORDINALS ); + } + + public static class ShockerBlob extends Blob { + + { + actPriority = BUFF_PRIO - 1; + } + + @Override + protected void evolve() { + + int cell; + for (int i = area.left; i < area.right; i++){ + for (int j = area.top; j < area.bottom; j++){ + cell = i + j* Dungeon.level.width(); + off[cell] = cur[cell] > 0 ? cur[cell] - 1 : 0; + + if (off[cell] > 0) { + volume += off[cell]; + } + + if (cur[cell] > 0 && off[cell] == 0){ + + Char ch = Actor.findChar(cell); + if (ch != null){ + ch.damage(2 + Dungeon.depth, Electricity.class); + + if (ch == Dungeon.hero && !ch.isAlive()) { + Dungeon.fail(NewTengu.class); + } + } + + } + } + } + + } + + @Override + public void use(BlobEmitter emitter) { + super.use(emitter); + + emitter.pour( SparkParticle.STATIC, 0.10f ); + } + + @Override + public String tileDesc() { + return Messages.get(this, "desc"); + } + } + + public static class ShockerItem extends Item { + + { + dropsDownHeap = true; + + image = ItemSpriteSheet.TENGU_SHOCKER; + } + + @Override + public boolean doPickUp( Hero hero ) { + GLog.w( Messages.get(this, "cant_pickup") ); + return false; + } + + //TODO change for when tengu throws this + @Override + protected void onThrow(int cell) { + super.onThrow(cell); + Buff.append(curUser, ShockerAbility.class).shockerPos = cell; + } + + @Override + public ItemSprite.Glowing glowing() { + return new ItemSprite.Glowing( 0xFFFFFF, 0.5f ); + } + } + + } } diff --git a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/Speck.java b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/Speck.java index e549de881..cd3118d0c 100644 --- a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/Speck.java +++ b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/Speck.java @@ -271,7 +271,7 @@ public class Speck extends Image { break; case STEAM: - speed.y = -Random.Float( 20, 30 ); + speed.y = -Random.Float( 10, 15 ); angularSpeed = Random.Float( +180 ); angle = Random.Float( 360 ); lifespan = 1f; diff --git a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/particles/SparkParticle.java b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/particles/SparkParticle.java index ff951c779..c5bd5311b 100644 --- a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/particles/SparkParticle.java +++ b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/effects/particles/SparkParticle.java @@ -36,7 +36,18 @@ public class SparkParticle extends PixelParticle { @Override public boolean lightMode() { return true; - }; + } + }; + + public static final Emitter.Factory STATIC = new Factory() { + @Override + public void emit( Emitter emitter, int index, float x, float y ) { + ((SparkParticle)emitter.recycle( SparkParticle.class )).resetStatic( x, y ); + } + @Override + public boolean lightMode() { + return true; + } }; public SparkParticle() { @@ -58,6 +69,15 @@ public class SparkParticle extends PixelParticle { speed.polar( -Random.Float( 3.1415926f ), Random.Float( 20, 40 ) ); } + public void resetStatic( float x, float y){ + reset(x, y); + + left = lifespan = Random.Float( 0.25f, 0.5f ); + + acc.set( 0, 0 ); + speed.set( 0, 0 ); + } + @Override public void update() { super.update(); diff --git a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/levels/NewPrisonBossLevel.java b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/levels/NewPrisonBossLevel.java index cf77bbd62..2cf91c163 100644 --- a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/levels/NewPrisonBossLevel.java +++ b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/levels/NewPrisonBossLevel.java @@ -429,9 +429,7 @@ public class NewPrisonBossLevel extends Level { cleanMapState(); tengu.state = tengu.HUNTING; - do { - tengu.pos = Random.Int(length()); - } while (solid[tengu.pos] || distance(tengu.pos, Dungeon.hero.pos) < 6); + tengu.pos = (arena.left + arena.width()/2) + width()*(arena.top+2); GameScene.add(tengu); tengu.notice(); diff --git a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/sprites/ItemSpriteSheet.java b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/sprites/ItemSpriteSheet.java index ab177f516..35f55e062 100644 --- a/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/sprites/ItemSpriteSheet.java +++ b/core/src/main/java/com/shatteredpixel/shatteredpixeldungeon/sprites/ItemSpriteSheet.java @@ -81,6 +81,9 @@ public class ItemSpriteSheet { public static final int GUIDE_PAGE = UNCOLLECTIBLE+6; public static final int ALCH_PAGE = UNCOLLECTIBLE+7; + + public static final int TENGU_BOMB = UNCOLLECTIBLE+9; + public static final int TENGU_SHOCKER = UNCOLLECTIBLE+10; static{ assignItemRect(GOLD, 15, 13); assignItemRect(DEWDROP, 10, 10); @@ -90,6 +93,9 @@ public class ItemSpriteSheet { assignItemRect(GUIDE_PAGE, 10, 11); assignItemRect(ALCH_PAGE, 10, 11); + + assignItemRect(TENGU_BOMB, 10, 10); + assignItemRect(TENGU_SHOCKER, 10, 10); } private static final int CONTAINERS = xy(1, 3); //16 slots diff --git a/core/src/main/resources/com/shatteredpixel/shatteredpixeldungeon/messages/actors/actors.properties b/core/src/main/resources/com/shatteredpixel/shatteredpixeldungeon/messages/actors/actors.properties index fb7b7d546..40f2afd79 100644 --- a/core/src/main/resources/com/shatteredpixel/shatteredpixeldungeon/messages/actors/actors.properties +++ b/core/src/main/resources/com/shatteredpixel/shatteredpixeldungeon/messages/actors/actors.properties @@ -590,6 +590,18 @@ actors.mobs.newtengu.defeated=Free at last... actors.mobs.newtengu.rankings_desc=Assassinated by the Tengu actors.mobs.newtengu.desc=A famous and enigmatic assassin, named for the mask grafted to his face.\n\nTengu is held down with large clasps on his wrists and knees, though he seems to have gotten rid of his chains long ago.\n\nHe will try to use traps, deceptive magic, and precise attacks to eliminate the only thing stopping his escape: you. +actors.mobs.newtengu$bombability$bombblob.desc=A cloud of superheated smoke is billowing here. Watch out, it's going to explode! +actors.mobs.newtengu$bombability$bombitem.name=Smoke Bomb +actors.mobs.newtengu$bombability$bombitem.cant_pickup=It's stuck to the ground, you can't move it! +actors.mobs.newtengu$bombability$bombitem.desc=Tengu has thrown a strange looking smoke bomb here, which is billowing a thick hot smoke. It's making a loud ticking noise, as if its counting down to an explosion!\n\nThe bomb has anchored itself to the ground, so you can't pick it up. + +actors.mobs.newtengu$fireability$fireblob.desc=Tengu has thrown a fine powder that seems to be catching into steam here, it's about to ignite! + +actors.mobs.newtengu$shockerability$shockerblob.desc=Electrical energy is building here, anything standing on this tile will be shocked next turn! +actors.mobs.newtengu$shockerability$shockeritem.name=Shocker +actors.mobs.newtengu$shockerability$shockeritem.cant_pickup=It's putting out too much electricity, you can't grab it! +actors.mobs.newtengu$shockerability$shockeritem.desc=Tengu has thrown a curious machine here, which seems to be made of robot parts. The machine is constantly arcing electricity around it, but it seems to be going in a predictable pattern.\n\nWith all the electricity arcing around it, there's no way you can pick this up. + actors.mobs.oldtengu.name=Tengu actors.mobs.oldtengu.notice_mine=You're mine, %s! actors.mobs.oldtengu.notice_face=Face me, %s!